diff --git a/.claude/commands/reflect.md b/.claude/commands/reflect.md
new file mode 100644
index 00000000..2ee23479
--- /dev/null
+++ b/.claude/commands/reflect.md
@@ -0,0 +1,37 @@
+---
+description: Reflect on this session to extract and store learned facts about the codebase
+---
+
+Analyze the current conversation to extract useful facts that should be remembered for future sessions. Focus on:
+
+1. **Corrections**: Things the user corrected you about
+2. **Clarifications**: Misunderstandings about how the codebase works
+3. **Patterns**: Important conventions or patterns you learned
+4. **Gotchas**: Surprising behaviors or edge cases discovered
+5. **Locations**: Files or code that was hard to find
+
+## Instructions
+
+1. Read the existing facts file at `.claude/learned-facts.md`
+2. Review this conversation for learnings worth preserving
+3. For each new fact:
+ - Write it concisely (1-2 sentences max)
+ - Include context for why it matters
+ - Add today's date
+4. Skip facts that duplicate existing entries
+5. Append new facts to `.claude/learned-facts.md`
+
+## Fact Format
+
+Use this format for each fact:
+```
+- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_
+```
+
+## Example Facts
+
+- **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_
+- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_
+- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_
+
+After updating the file, summarize what facts you added (or note if nothing new was learned this session).
diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md
new file mode 100644
index 00000000..0dba9d7e
--- /dev/null
+++ b/.claude/learned-facts.md
@@ -0,0 +1,27 @@
+# Learned Facts
+
+This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions.
+
+This file is auto-imported into CLAUDE.md and loaded at the start of each session.
+
+## Facts
+
+
+
+- **Native bottom tabs + useRouter conflict**: When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead. _(2025-01-09)_
+
+- **IntroSheet rendering location**: The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs. _(2025-01-09)_
+
+- **Intro modal trigger location**: The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation. _(2025-01-09)_
+
+- **Tab folder naming**: The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions. _(2025-01-09)_
+
+- **macOS header buttons fix**: Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app. _(2026-01-10)_
+
+- **Header button locations**: Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`. _(2026-01-10)_
+
+- **useNetworkAwareQueryClient limitations**: The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`. _(2026-01-10)_
+
+- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component โ `useMarkAsPlayed` hook โ `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
+
+- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/issue_report.yml b/.github/ISSUE_TEMPLATE/issue_report.yml
index 29904bd3..ccdb0d0e 100644
--- a/.github/ISSUE_TEMPLATE/issue_report.yml
+++ b/.github/ISSUE_TEMPLATE/issue_report.yml
@@ -77,13 +77,8 @@ body:
label: Streamyfin Version
description: What version of Streamyfin are you running?
options:
+ - 0.47.1
- 0.30.2
- - 0.29.0
- - 0.28.0
- - 0.27.0
- - 0.26.1
- - 0.26.0
- - 0.25.0
- older
- TestFlight/Development build
validations:
@@ -116,4 +111,4 @@ body:
id: additional-info
attributes:
label: Additional information
- description: Any additional context that might help us understand and reproduce the issue.
\ No newline at end of file
+ description: Any additional context that might help us understand and reproduce the issue.
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 2111dd2e..1037334b 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -3,7 +3,7 @@
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
-It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
+It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies
diff --git a/.github/crowdin.yml b/.github/crowdin.yml
deleted file mode 100644
index 4acc3208..00000000
--- a/.github/crowdin.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-"project_id_env": "CROWDIN_PROJECT_ID"
-"api_token_env": "CROWDIN_PERSONAL_TOKEN"
-"base_path": "."
-
-"preserve_hierarchy": true
-
-"files": [
- {
- "source": "translations/en.json",
- "translation": "translations/%two_letters_code%.json"
- }
-]
\ No newline at end of file
diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml
index c0aae509..5c8caa67 100644
--- a/.github/workflows/build-apps.yml
+++ b/.github/workflows/build-apps.yml
@@ -21,7 +21,7 @@ jobs:
steps:
- name: ๐ฅ Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -29,12 +29,12 @@ jobs:
show-progress: false
- name: ๐ Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -48,7 +48,7 @@ jobs:
bun run submodule-reload
- name: ๐พ Cache Gradle global
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -61,7 +61,7 @@ jobs:
run: bun run prebuild
- name: ๐พ Cache project Gradle (.gradle)
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -76,7 +76,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: ๐ค Upload APK artifact
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: |
@@ -92,7 +92,7 @@ jobs:
steps:
- name: ๐ฅ Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -100,12 +100,12 @@ jobs:
show-progress: false
- name: ๐ Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -119,7 +119,7 @@ jobs:
bun run submodule-reload
- name: ๐พ Cache Gradle global
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -132,7 +132,7 @@ jobs:
run: bun run prebuild:tv
- name: ๐พ Cache project Gradle (.gradle)
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -147,7 +147,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: ๐ค Upload APK artifact
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: |
@@ -163,7 +163,7 @@ jobs:
steps:
- name: ๐ฅ Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -171,12 +171,12 @@ jobs:
show-progress: false
- name: ๐ Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -215,7 +215,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: ๐ค Upload IPA artifact
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml
index cc606b19..2173ccfc 100644
--- a/.github/workflows/check-lockfile.yml
+++ b/.github/workflows/check-lockfile.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: ๐ฅ Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0
- name: ๐ Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.bun/install/cache
diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml
index d1a44239..b9fb8c59 100644
--- a/.github/workflows/ci-codeql.yml
+++ b/.github/workflows/ci-codeql.yml
@@ -24,20 +24,16 @@ jobs:
steps:
- name: ๐ฅ Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- with:
- ref: ${{ github.event.pull_request.head.sha || github.sha }}
- show-progress: false
- fetch-depth: 0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: ๐ Initialize CodeQL
- uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
+ uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: ๐ ๏ธ Autobuild
- uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
+ uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
- name: ๐งช Perform CodeQL Analysis
- uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
+ uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml
index a49669ad..69a3c86d 100644
--- a/.github/workflows/crowdin.yml
+++ b/.github/workflows/crowdin.yml
@@ -23,12 +23,12 @@ jobs:
steps:
- name: ๐ฅ Checkout Repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: ๐ Sync Translations with Crowdin
- uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
+ uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
with:
upload_sources: true
upload_translations: true
@@ -40,11 +40,12 @@ jobs:
pull_request_base_branch_name: "develop"
pull_request_labels: "๐ translation"
# Quality control options
- skip_untranslated_strings: true
+ skip_untranslated_strings: false
+ skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
- GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml
index 55001958..43bb3418 100644
--- a/.github/workflows/linting.yml
+++ b/.github/workflows/linting.yml
@@ -51,7 +51,7 @@ jobs:
contents: read
steps:
- name: Checkout Repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -69,14 +69,14 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: ๐ Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: ๐ Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
@@ -100,19 +100,19 @@ jobs:
steps:
- name: "๐ฅ Checkout PR code"
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: "๐ข Setup Node.js"
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
+ uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: '24.x'
- name: "๐ Setup Bun"
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
deleted file mode 100644
index 34537097..00000000
--- a/.github/workflows/stale.yml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: ๐ Handle Stale Issues
-
-on:
- schedule:
- # Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
- - cron: "30 1 * * *"
-
-jobs:
- stale-issues:
- name: ๐๏ธ Cleanup Stale Issues
- runs-on: ubuntu-24.04
- permissions:
- issues: write
- pull-requests: write
-
- steps:
- - name: ๐ Mark/Close Stale Issues
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- with:
- # Global settings
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- operations-per-run: 500 # Increase if you have >1000 issues
- enable-statistics: true
-
- # Issue configuration
- days-before-issue-stale: 90
- days-before-issue-close: 7
- stale-issue-label: "๐ฐ๏ธ stale"
- exempt-issue-labels: "Roadmap v1,help needed,enhancement"
-
- # Notifications messages
- stale-issue-message: |
- โณ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
-
- **Next steps:**
- - If this is still relevant, add a comment to keep it open
- - Otherwise, it will be closed in 7 days
-
- Thank you for your contributions! ๐
-
- close-issue-message: |
- ๐ฎ This issue has been automatically closed due to inactivity (7 days since being marked stale).
-
- **Need to reopen?**
- Click "Reopen" and add a comment explaining why this should stay open.
-
- # Disable PR handling
- days-before-pr-stale: -1
- days-before-pr-close: -1
diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml
index dc76a074..21615767 100644
--- a/.github/workflows/update-issue-form.yml
+++ b/.github/workflows/update-issue-form.yml
@@ -18,10 +18,10 @@ jobs:
steps:
- name: ๐ฅ Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: "๐ข Setup Node.js"
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
+ uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: '24.x'
cache: 'npm'
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push
- name: ๐ฌ Commit and create pull request
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
+ uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report
diff --git a/.gitignore b/.gitignore
index 2813f770..c022785c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,7 +19,7 @@ web-build/
/androidtv
# Module-specific Builds
-modules/vlc-player/android/build
+modules/mpv-player/android/build
modules/player/android
modules/hls-downloader/android/build
@@ -51,6 +51,7 @@ npm-debug.*
.ruby-lsp
.cursor/
.claude/
+CLAUDE.md
# Environment and Configuration
expo-env.d.ts
@@ -66,3 +67,6 @@ streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files
/version-backup-*
modules/background-downloader/android/build/*
+/modules/sf-player/android/build
+/modules/music-controls/android/build
+/modules/mpv-player/android/build
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..cc3b0a53
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,136 @@
+# CLAUDE.md
+
+@.claude/learned-facts.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
+
+## Development Commands
+
+**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.**
+
+```bash
+# Setup
+bun i && bun run submodule-reload
+
+# Development builds
+bun run prebuild # Mobile prebuild
+bun run ios # Run iOS
+bun run android # Run Android
+
+# TV builds (suffix with :tv)
+bun run prebuild:tv
+bun run ios:tv
+bun run android:tv
+
+# Code quality
+bun run typecheck # TypeScript check
+bun run check # BiomeJS check
+bun run lint # BiomeJS lint + fix
+bun run format # BiomeJS format
+bun run test # Run all checks (typecheck, lint, format, doctor)
+
+# iOS-specific
+bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
+```
+
+## Tech Stack
+
+- **Runtime**: Bun
+- **Framework**: React Native (Expo SDK 54)
+- **Language**: TypeScript (strict mode)
+- **State Management**: Jotai (global state atoms) + React Query (server state)
+- **API**: Jellyfin SDK (`@jellyfin/sdk`)
+- **Navigation**: Expo Router (file-based)
+- **Linting/Formatting**: BiomeJS
+- **Storage**: react-native-mmkv
+
+## Architecture
+
+### File Structure
+
+- `app/` - Expo Router screens with file-based routing
+- `components/` - Reusable UI components
+- `providers/` - React Context providers
+- `hooks/` - Custom React hooks
+- `utils/` - Utilities including Jotai atoms
+- `modules/` - Native modules (vlc-player, mpv-player, background-downloader)
+- `translations/` - i18n translation files
+
+### Key Patterns
+
+**State Management**:
+- Global state uses Jotai atoms in `utils/atoms/`
+- `settingsAtom` in `utils/atoms/settings.ts` for app settings
+- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
+- Server state uses React Query with `@tanstack/react-query`
+
+**Jellyfin API Access**:
+- Use `apiAtom` from `JellyfinProvider` for authenticated API calls
+- Access user via `userAtom`
+- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api`
+
+**Navigation**:
+- File-based routing in `app/` directory
+- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
+- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
+- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation:
+ ```typescript
+ // โ
Correct
+ import useRouter from "@/hooks/useAppRouter";
+ const router = useRouter();
+
+ // โ Never use this
+ import { useRouter } from "expo-router";
+ import { router } from "expo-router";
+ ```
+
+**Offline Mode**:
+- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content
+- Use `useOfflineMode()` hook to check if current context is offline
+- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context
+
+**Providers** (wrapping order in `app/_layout.tsx`):
+1. JotaiProvider
+2. QueryClientProvider
+3. JellyfinProvider (auth, API)
+4. NetworkStatusProvider
+5. PlaySettingsProvider
+6. WebSocketProvider
+7. DownloadProvider
+8. MusicPlayerProvider
+
+### Native Modules
+
+Located in `modules/`:
+- `vlc-player` - VLC video player integration
+- `mpv-player` - MPV video player integration (iOS)
+- `background-downloader` - Background download functionality
+- `sf-player` - Swift player module
+
+### Path Aliases
+
+Use `@/` prefix for imports (configured in `tsconfig.json`):
+```typescript
+import { useSettings } from "@/utils/atoms/settings";
+import { apiAtom } from "@/providers/JellyfinProvider";
+```
+
+## Coding Standards
+
+- Use TypeScript for all files (no .js)
+- Use functional React components with hooks
+- Use Jotai atoms for global state, React Query for server state
+- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
+- Handle both mobile and TV navigation patterns
+- Use existing atoms, hooks, and utilities before creating new ones
+- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
+
+## Platform Considerations
+
+- TV version uses `:tv` suffix for scripts
+- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
+- Some features disabled on TV (e.g., notifications, Chromecast)
diff --git a/README.md b/README.md
index 90349db4..fbbf8561 100644
--- a/README.md
+++ b/README.md
@@ -16,53 +16,75 @@
-
+
## ๐ Features
-- ๐ **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
-- ๐ผ๏ธ **Trickplay images**: The new golden standard for chapter previews when seeking
-- ๐ฅ **Download media**: Save your media locally and watch it offline
-- โ๏ธ **Settings management**: Manage app configurations for all users through our plugin
-- ๐ค **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
-- ๐๏ธ **Sessions view:** View all active sessions currently streaming on your server
+### ๐ฌ Media Playback
+- ๐ **Skip Intro / Credits**: Automatically skip intros and credits during playback
+- ๐ผ๏ธ **Trickplay Images**: Chapter previews with thumbnails when seeking
+- ๐ต **Music Library**: Full support for music playback with playlists and queue management
+- ๐บ **Live TV**: Watch and record live television streams
- ๐ก **Chromecast**: Cast your media to any Chromecast-enabled device
+- ๐ฅ **MPV Player**: Powerful open-source player with wide format support
-## ๐งช Experimental Features
+### ๐ฑ Media Management
+- ๐ฅ **Download Media**: Save movies, shows, and music locally for offline viewing
+- โญ **Favorites**: Quick access to your favorite content
+- ๐ **Watchlists**: Create and manage custom watchlists with Streamystats integration
+- ๐ **Continue Watching**: Pick up right where you left off
+- ๐ฏ **Next Up**: Smart suggestions for your next episode
-Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
+### โ๏ธ Advanced Features
+- ๐ค **Seerr Integration**: Request new media directly in the app
+- ๐ **Smart Search**: Powerful search with Marlin Search and Streamystats support
+- ๐๏ธ **Active Sessions**: View all active streams on your server
+- ๐ **Multi-Language**: Available in 20+ languages with Crowdin integration
+- ๐จ **Customizable**: Personalize your home screen and settings
+- ๐ **Plugin System**: Centralized settings sync across all devices via Jellyfin plugin
-### ๐ฅ Downloading
+## ๐งฉ How It Works
+
+### ๐ฅ Downloads
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
### ๐งฉ Streamyfin Plugin
-The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
+The Jellyfin Plugin for Streamyfin synchronizes settings across all your devices and users. Install it on your Jellyfin server to enable:
- Automatic Seerr login with no user input required
-- Set your preferred default languages
-- Configure download method and search provider
-- Personalize your home screen
+- Default language preferences for audio and subtitles
+- Configure download settings and search providers (Marlin, Streamystats)
+- Customize your home screen layout and sections
+- Centralized configuration management
- And much more
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
-### ๐ก Chromecast
+### ๐ฌ MPV Player
-Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
+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.
-### ๐ Jellysearch
+Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
-[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
+### ๐ต Music Library
-> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
+Full music library support with playlists, queue management, background playback, and offline downloads.
+
+### ๐ Search Providers
+
+Streamyfin supports multiple search providers:
+
+- **Marlin Search**: Fast semantic search for your Jellyfin library
+- **Streamystats**: Advanced statistics and personalized recommendations
+- **Jellysearch**: Fast full-text search proxy ([Jellysearch](https://gitlab.com/DomiStyle/jellysearch))
## ๐ฃ๏ธ Roadmap
-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
@@ -70,6 +92,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
+
### ๐งช Beta Testing
@@ -101,12 +124,13 @@ You can contribute translations directly on our [Crowdin project page](https://c
### ๐จโ๐ป Development Info
-1. Use node `>20`
-2. Install dependencies `bun i && bun run submodule-reload`
-3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
-4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
-4. run `npm run prebuild`
-5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
+1. Use Node.js `>20`
+2. Install dependencies: `bun i && bun run submodule-reload`
+3. Make sure you have Xcode and/or Android Studio installed ([Expo setup guide](https://docs.expo.dev/workflow/android-studio-emulator/))
+ - If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
+4. Install the [BiomeJS extension](https://biomejs.dev/) in your IDE
+5. Run `npm run prebuild`
+6. Create an Expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
For the TV version suffix the npm commands with `:tv`.
@@ -124,10 +148,20 @@ Need assistance or have any questions?
## โ FAQ
-1. Q: Why can't I see my libraries in Streamyfin?
- A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
-2. Q: Why can't I see my music library?
- A: We don't currently support music and are unlikely to support music in the near future
+1. **Q: Why can't I see my libraries in Streamyfin?**
+ A: Ensure your Jellyfin server is running a recent version (10.10.0+) and that you have proper permissions to access the libraries.
+
+2. **Q: How do I enable downloads?**
+ A: Downloads use FFmpeg to convert HLS streams. Ensure your server has transcoding enabled and sufficient resources.
+
+3. **Q: Does Streamyfin support subtitles?**
+ A: Yes, with full customization including size, color, position, and automatic language selection.
+
+4. **Q: Can I use Streamyfin on Apple TV or Android TV?**
+ A: Yes, Streamyfin has dedicated TV builds optimized for remote control navigation. Please note that TV platforms are currently in early development and not very stable. Android TV is currently the most reliable platform for testing.
+
+5. **Q: How do I set up Seerr integration?**
+ A: Go to Settings โ Plugins โ Seerr, enter your server URL and Jellyfin credentials.
## ๐ Credits
@@ -228,6 +262,7 @@ We also thank all other developers who have contributed to Streamyfin, your effo
A special mention to the following people and projects for their contributions:
+- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
@@ -240,7 +275,9 @@ A special mention to the following people and projects for their contributions:
## ๐ License
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
+
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
+
Key points of the MPL-2.0:
- You can use the software for any purpose
@@ -249,10 +286,13 @@ Key points of the MPL-2.0:
- You must disclose your source code for any modifications to the covered files
- Larger works may combine MPL code with code under other licenses
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
-- For the full text of the license, please see the LICENSE file in this repository
+
+For the full text of the license, please see the LICENSE file in this repository.
## โ ๏ธ Disclaimer
+
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
## ๐ค Sponsorship
-VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
+
+VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster).
diff --git a/app.json b/app.json
index 5d4e9f4a..288f3e3a 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.48.0",
+ "version": "0.52.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -17,6 +17,7 @@
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": ["audio", "fetch"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
+ "NSLocationWhenInUseUsageDescription": "Streamyfin uses your location to detect your home WiFi network for automatic local server switching.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
},
@@ -28,13 +29,16 @@
"usesNonExemptEncryption": false
},
"supportsTablet": true,
+ "entitlements": {
+ "com.apple.developer.networking.wifi-info": true
+ },
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": "./assets/images/icon-ios-liquid-glass.icon",
"appleTeamId": "MWD5K362T8"
},
"android": {
"jsEngine": "hermes",
- "versionCode": 85,
+ "versionCode": 92,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -44,7 +48,8 @@
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
- "android.permission.WRITE_SETTINGS"
+ "android.permission.WRITE_SETTINGS",
+ "android.permission.ACCESS_FINE_LOCATION"
],
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
"googleServicesFile": "./google-services.json"
@@ -53,19 +58,7 @@
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
- [
- "react-native-video",
- {
- "enableNotificationControls": true,
- "enableBackgroundAudio": true,
- "androidExtensions": {
- "useExoplayerRtsp": false,
- "useExoplayerSmoothStreaming": false,
- "useExoplayerHls": true,
- "useExoplayerDash": false
- }
- }
- ],
+ "./plugins/withExcludeMedia3Dash.js",
[
"expo-build-properties",
{
@@ -75,11 +68,11 @@
},
"android": {
"buildArchs": ["arm64-v8a", "x86_64"],
- "compileSdkVersion": 35,
+ "compileSdkVersion": 36,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"kotlinVersion": "2.0.21",
- "minSdkVersion": 24,
+ "minSdkVersion": 26,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
@@ -97,12 +90,6 @@
"initialOrientation": "DEFAULT"
}
],
- [
- "expo-sensors",
- {
- "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
- }
- ],
"expo-localization",
"expo-asset",
[
@@ -133,7 +120,14 @@
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
- ["./plugins/withGradleProperties.js"]
+ ["./plugins/withGradleProperties.js"],
+ [
+ "./plugins/withGitPod.js",
+ {
+ "podName": "MPVKit-GPL",
+ "podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
+ }
+ ]
],
"experiments": {
"typedRoutes": true
diff --git a/app/(auth)/(tabs)/(custom-links)/index.tsx b/app/(auth)/(tabs)/(custom-links)/index.tsx
index 7dad5453..8dbb1866 100644
--- a/app/(auth)/(tabs)/(custom-links)/index.tsx
+++ b/app/(auth)/(tabs)/(custom-links)/index.tsx
@@ -29,7 +29,7 @@ export default function menuLinks() {
);
const config = response?.data;
- if (!config && !Object.hasOwn(config, "menuLinks")) {
+ if (!config || !Object.hasOwn(config, "menuLinks")) {
console.error("Menu links not found");
return;
}
diff --git a/app/(auth)/(tabs)/(favorites)/see-all.tsx b/app/(auth)/(tabs)/(favorites)/see-all.tsx
new file mode 100644
index 00000000..e3b0198a
--- /dev/null
+++ b/app/(auth)/(tabs)/(favorites)/see-all.tsx
@@ -0,0 +1,212 @@
+import type { Api } from "@jellyfin/sdk";
+import type {
+ BaseItemDto,
+ BaseItemKind,
+} from "@jellyfin/sdk/lib/generated-client";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { Stack, useLocalSearchParams } from "expo-router";
+import { t } from "i18next";
+import { useAtom } from "jotai";
+import { useCallback, useMemo } from "react";
+import { useWindowDimensions, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
+import { ItemCardText } from "@/components/ItemCardText";
+import { Loader } from "@/components/Loader";
+import { ItemPoster } from "@/components/posters/ItemPoster";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+type FavoriteTypes =
+ | "Series"
+ | "Movie"
+ | "Episode"
+ | "Video"
+ | "BoxSet"
+ | "Playlist";
+
+const favoriteTypes: readonly FavoriteTypes[] = [
+ "Series",
+ "Movie",
+ "Episode",
+ "Video",
+ "BoxSet",
+ "Playlist",
+] as const;
+
+function isFavoriteType(value: unknown): value is FavoriteTypes {
+ return (
+ typeof value === "string" &&
+ (favoriteTypes as readonly string[]).includes(value)
+ );
+}
+
+export default function FavoritesSeeAllScreen() {
+ const insets = useSafeAreaInsets();
+ const { width: screenWidth } = useWindowDimensions();
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+
+ const searchParams = useLocalSearchParams<{
+ type?: string;
+ title?: string;
+ }>();
+ const typeParam = searchParams.type;
+ const titleParam = searchParams.title;
+
+ const itemType = useMemo(() => {
+ if (!isFavoriteType(typeParam)) return null;
+ return typeParam as BaseItemKind;
+ }, [typeParam]);
+
+ const headerTitle = useMemo(() => {
+ if (typeof titleParam === "string" && titleParam.trim().length > 0)
+ return titleParam;
+ return "";
+ }, [titleParam]);
+
+ const pageSize = 50;
+
+ const fetchItems = useCallback(
+ async ({ pageParam }: { pageParam: number }): Promise => {
+ if (!api || !user?.Id || !itemType) return [];
+
+ const response = await getItemsApi(api as Api).getItems({
+ userId: user.Id,
+ sortBy: ["SeriesSortName", "SortName"],
+ sortOrder: ["Ascending"],
+ filters: ["IsFavorite"],
+ recursive: true,
+ fields: ["PrimaryImageAspectRatio"],
+ collapseBoxSetItems: false,
+ excludeLocationTypes: ["Virtual"],
+ enableTotalRecordCount: true,
+ startIndex: pageParam,
+ limit: pageSize,
+ includeItemTypes: [itemType],
+ });
+
+ return response.data.Items || [];
+ },
+ [api, itemType, user?.Id],
+ );
+
+ const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
+ useInfiniteQuery({
+ queryKey: ["favorites", "see-all", itemType],
+ queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
+ getNextPageParam: (lastPage, pages) => {
+ if (!lastPage || lastPage.length < pageSize) return undefined;
+ return pages.reduce((acc, page) => acc + page.length, 0);
+ },
+ initialPageParam: 0,
+ enabled: !!api && !!user?.Id && !!itemType,
+ });
+
+ const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
+
+ const nrOfCols = useMemo(() => {
+ if (screenWidth < 350) return 2;
+ if (screenWidth < 600) return 3;
+ if (screenWidth < 900) return 5;
+ return 6;
+ }, [screenWidth]);
+
+ const renderItem = useCallback(
+ ({ item, index }: { item: BaseItemDto; index: number }) => (
+
+
+
+
+
+
+ ),
+ [nrOfCols],
+ );
+
+ const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
+
+ const handleEndReached = useCallback(() => {
+ if (hasNextPage) {
+ fetchNextPage();
+ }
+ }, [fetchNextPage, hasNextPage]);
+
+ return (
+ <>
+
+ {!itemType ? (
+
+
+ {t("favorites.noData", { defaultValue: "No items found." })}
+
+
+ ) : isLoading ? (
+
+
+
+ ) : (
+ (
+
+ )}
+ ListEmptyComponent={
+
+
+ {t("home.no_items", { defaultValue: "No items" })}
+
+
+ }
+ ListFooterComponent={
+ isFetching ? (
+
+
+
+ ) : null
+ }
+ />
+ )}
+ >
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx
index bfbebed5..761e868c 100644
--- a/app/(auth)/(tabs)/(home)/_layout.tsx
+++ b/app/(auth)/(tabs)/(home)/_layout.tsx
@@ -1,8 +1,10 @@
import { Feather, Ionicons } from "@expo/vector-icons";
-import { Stack, useRouter } from "expo-router";
+import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
-import { Platform, TouchableOpacity, View } from "react-native";
+import { Platform, View } from "react-native";
+import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
+import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
@@ -46,32 +48,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"),
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
- ),
- }}
- />
- (
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
+
),
}}
/>
@@ -84,13 +67,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
@@ -102,13 +85,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
@@ -120,13 +103,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
@@ -138,13 +121,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
@@ -156,13 +139,31 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
+ ),
+ }}
+ />
+ (
+ _router.back()}
+ className='pl-0.5'
+ style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
+ >
+
+
),
}}
/>
@@ -174,13 +175,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
@@ -192,13 +193,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
@@ -210,31 +211,67 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
(
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
+ ),
+ }}
+ />
+ (
+ _router.back()}
+ className='pl-0.5'
+ style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
+ >
+
+
+ ),
+ }}
+ />
+ (
+ _router.back()}
+ className='pl-0.5'
+ style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
+ >
+
+
),
}}
/>
@@ -246,13 +283,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
@@ -264,27 +301,32 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
(
- _router.back()} className='pl-0.5'>
+ _router.back()}
+ className='pl-0.5'
+ style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
+ >
-
+
),
- presentation: "modal",
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -295,9 +337,9 @@ export default function IndexLayout() {
options={{
title: "",
headerLeft: () => (
- _router.back()} className='pl-0.5'>
+ _router.back()} className='pl-0.5'>
-
+
),
headerShown: true,
headerBlurEffect: "prominent",
@@ -313,13 +355,13 @@ const SettingsButton = () => {
const router = useRouter();
return (
- {
router.push("/(auth)/settings");
}}
>
-
+
);
};
@@ -328,7 +370,7 @@ const SessionsButton = () => {
const { sessions = [] } = useSessions({} as useSessionsProps);
return (
- {
router.push("/(auth)/sessions");
}}
@@ -339,6 +381,6 @@ const SessionsButton = () => {
color={sessions.length === 0 ? "white" : "#9333ea"}
size={28}
/>
-
+
);
};
diff --git a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
deleted file mode 100644
index e870f5c0..00000000
--- a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { FlashList } from "@shopify/flash-list";
-import { router, useLocalSearchParams, useNavigation } from "expo-router";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { Alert, Platform, TouchableOpacity, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { Text } from "@/components/common/Text";
-import { EpisodeCard } from "@/components/downloads/EpisodeCard";
-import {
- SeasonDropdown,
- type SeasonIndexState,
-} from "@/components/series/SeasonDropdown";
-import { useDownload } from "@/providers/DownloadProvider";
-import { storage } from "@/utils/mmkv";
-
-export default function page() {
- const navigation = useNavigation();
- const local = useLocalSearchParams();
- const { seriesId, episodeSeasonIndex } = local as {
- seriesId: string;
- episodeSeasonIndex: number | string | undefined;
- };
-
- const [seasonIndexState, setSeasonIndexState] = useState(
- {},
- );
- const { downloadedItems, deleteItems } = useDownload();
- const insets = useSafeAreaInsets();
-
- const series = useMemo(() => {
- try {
- return (
- downloadedItems
- ?.filter((f) => f.item.SeriesId === seriesId)
- ?.sort(
- (a, b) =>
- (a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
- ) || []
- );
- } catch {
- return [];
- }
- }, [downloadedItems, seriesId]);
-
- // Group episodes by season in a single pass
- const seasonGroups = useMemo(() => {
- const groups: Record = {};
-
- series.forEach((episode) => {
- const seasonNumber = episode.item.ParentIndexNumber;
- if (seasonNumber !== undefined && seasonNumber !== null) {
- if (!groups[seasonNumber]) {
- groups[seasonNumber] = [];
- }
- groups[seasonNumber].push(episode.item);
- }
- });
-
- // Sort episodes within each season
- Object.values(groups).forEach((episodes) => {
- episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
- });
-
- return groups;
- }, [series]);
-
- // Get unique seasons (just the season numbers, sorted)
- const uniqueSeasons = useMemo(() => {
- const seasonNumbers = Object.keys(seasonGroups)
- .map(Number)
- .sort((a, b) => a - b);
- return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
- }, [seasonGroups]);
-
- const seasonIndex =
- seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
- episodeSeasonIndex ??
- series?.[0]?.item?.ParentIndexNumber ??
- "";
-
- const groupBySeason = useMemo(() => {
- return seasonGroups[Number(seasonIndex)] ?? [];
- }, [seasonGroups, seasonIndex]);
-
- const initialSeasonIndex = useMemo(
- () =>
- groupBySeason?.[0]?.ParentIndexNumber ??
- series?.[0]?.item?.ParentIndexNumber,
- [groupBySeason, series],
- );
-
- useEffect(() => {
- if (series.length > 0) {
- navigation.setOptions({
- title: series[0].item.SeriesName,
- });
- } else {
- storage.remove(seriesId);
- router.back();
- }
- }, [series]);
-
- const deleteSeries = useCallback(() => {
- Alert.alert(
- "Delete season",
- "Are you sure you want to delete the entire season?",
- [
- {
- text: "Cancel",
- style: "cancel",
- },
- {
- text: "Delete",
- onPress: () =>
- deleteItems(
- groupBySeason
- .map((item) => item.Id)
- .filter((id) => id !== undefined),
- ),
- style: "destructive",
- },
- ],
- );
- }, [groupBySeason, deleteItems]);
-
- const ListHeaderComponent = useCallback(() => {
- if (series.length === 0) return null;
-
- return (
-
- {
- setSeasonIndexState((prev) => ({
- ...prev,
- [series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
- }));
- }}
- />
-
- {groupBySeason.length}
-
-
-
-
-
-
-
- );
- }, [
- series,
- uniqueSeasons,
- seasonIndexState,
- initialSeasonIndex,
- groupBySeason,
- deleteSeries,
- ]);
-
- return (
-
- }
- keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
- ListHeaderComponent={ListHeaderComponent}
- contentInsetAdjustmentBehavior='automatic'
- contentContainerStyle={{
- paddingHorizontal: 16,
- paddingLeft: insets.left + 16,
- paddingRight: insets.right + 16,
- paddingTop: Platform.OS === "android" ? 10 : 8,
- }}
- />
-
- );
-}
diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx
index c71bebd8..fb8ef0b9 100644
--- a/app/(auth)/(tabs)/(home)/downloads/index.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx
@@ -1,15 +1,10 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet";
-import { useNavigation, useRouter } from "expo-router";
+import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import {
- Alert,
- Platform,
- ScrollView,
- TouchableOpacity,
- View,
-} from "react-native";
+import { Alert, Platform, ScrollView, View } from "react-native";
+import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
@@ -18,8 +13,10 @@ import ActiveDownloads from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
+import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types";
+import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log";
@@ -103,12 +100,12 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
-
f.item) || []} />
-
+
),
});
}, [downloadedFiles]);
@@ -166,145 +163,99 @@ export default function page() {
);
return (
-
-
-
- {/* Queue card - hidden */}
- {/*
+
+
+
+
+
+
+
+ {movies.length > 0 && (
+
+
- {t("home.downloads.queue")}
+ {t("home.downloads.movies")}
-
- {t("home.downloads.queue_hint")}
-
-
- {queue.map((q, index) => (
-
- router.push(`/(auth)/items/page?id=${q.item.Id}`)
- }
- className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
- key={index}
- >
-
- {q.item.Name}
-
- {q.item.Type}
-
-
- {
- removeProcess(q.id);
- setQueue((prev) => {
- if (!prev) return [];
- return [...prev.filter((i) => i.id !== q.id)];
- });
- }}
- >
-
-
-
+
+ {movies?.length}
+
+
+
+
+ {movies?.map((item) => (
+
+
+
))}
-
- {queue.length === 0 && (
-
- {t("home.downloads.no_items_in_queue")}
-
- )}
- */}
-
-
-
-
- {movies.length > 0 && (
-
-
-
- {t("home.downloads.movies")}
-
-
- {movies?.length}
-
+
-
-
- {movies?.map((item) => (
-
-
-
- ))}
-
-
-
- )}
- {groupedBySeries.length > 0 && (
-
-
-
- {t("home.downloads.tvseries")}
-
-
-
- {groupedBySeries?.length}
+ )}
+ {groupedBySeries.length > 0 && (
+
+
+
+ {t("home.downloads.tvseries")}
+
+
+ {groupedBySeries?.length}
+
+
-
-
-
- {groupedBySeries?.map((items) => (
-
- i.item)}
+
+
+ {groupedBySeries?.map((items) => (
+
-
- ))}
-
-
-
- )}
-
- {otherMedia.length > 0 && (
-
-
-
- {t("home.downloads.other_media")}
-
-
- {otherMedia?.length}
-
+ >
+ i.item)}
+ key={items[0].item.SeriesId}
+ />
+
+ ))}
+
+
-
-
- {otherMedia?.map((item) => (
-
-
-
- ))}
+ )}
+
+ {otherMedia.length > 0 && (
+
+
+
+ {t("home.downloads.other_media")}
+
+
+
+ {otherMedia?.length}
+
+
-
-
- )}
- {downloadedFiles?.length === 0 && (
-
-
- {t("home.downloads.no_downloaded_items")}
-
-
- )}
-
-
+
+
+ {otherMedia?.map((item) => (
+
+
+
+ ))}
+
+
+
+ )}
+ {downloadedFiles?.length === 0 && (
+
+
+ {t("home.downloads.no_downloaded_items")}
+
+
+ )}
+
+
+
);
}
diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx
deleted file mode 100644
index 572a4980..00000000
--- a/app/(auth)/(tabs)/(home)/intro/page.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { Feather, Ionicons } from "@expo/vector-icons";
-import { Image } from "expo-image";
-import { useFocusEffect, useRouter } from "expo-router";
-import { useCallback } from "react";
-import { useTranslation } from "react-i18next";
-import { Linking, Platform, TouchableOpacity, View } from "react-native";
-import { Button } from "@/components/Button";
-import { Text } from "@/components/common/Text";
-import { storage } from "@/utils/mmkv";
-
-export default function page() {
- const router = useRouter();
- const { t } = useTranslation();
-
- useFocusEffect(
- useCallback(() => {
- storage.set("hasShownIntro", true);
- }, []),
- );
-
- return (
-
-
-
- {t("home.intro.welcome_to_streamyfin")}
-
-
- {t("home.intro.a_free_and_open_source_client_for_jellyfin")}
-
-
-
-
-
- {t("home.intro.features_title")}
-
- {t("home.intro.features_description")}
-
-
-
- Jellyseerr
-
- {t("home.intro.jellyseerr_feature_description")}
-
-
-
- {!Platform.isTV && (
- <>
-
-
-
-
-
-
- {t("home.intro.downloads_feature_title")}
-
-
- {t("home.intro.downloads_feature_description")}
-
-
-
-
-
-
-
-
- Chromecast
-
- {t("home.intro.chromecast_feature_description")}
-
-
-
- >
- )}
-
-
-
-
-
-
- {t("home.intro.centralised_settings_plugin_title")}
-
-
-
- {t("home.intro.centralised_settings_plugin_description")}{" "}
-
- {
- Linking.openURL(
- "https://github.com/streamyfin/jellyfin-plugin-streamyfin",
- );
- }}
- >
-
- {t("home.intro.read_more")}
-
-
-
-
-
-
-
-
- {
- router.back();
- router.push("/settings");
- }}
- className='mt-4'
- >
-
- {t("home.intro.go_to_settings_button")}
-
-
-
-
- );
-}
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 4bd917d6..76675ae8 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -1,4 +1,4 @@
-import { useNavigation, useRouter } from "expo-router";
+import { useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useEffect } from "react";
@@ -11,6 +11,7 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { UserInfo } from "@/components/settings/UserInfo";
+import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
export default function settings() {
@@ -70,6 +71,11 @@ export default function settings() {
showArrow
title={t("home.settings.audio_subtitles.title")}
/>
+ router.push("/settings/music/page")}
+ showArrow
+ title={t("home.settings.music.title")}
+ />
router.push("/settings/appearance/page")}
showArrow
@@ -85,6 +91,11 @@ export default function settings() {
showArrow
title={t("home.settings.intro.title")}
/>
+ router.push("/settings/network/page")}
+ showArrow
+ title={t("home.settings.network.title")}
+ />
router.push("/settings/logs/page")}
showArrow
diff --git a/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx
index a0b3bab9..5fd815d2 100644
--- a/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx
@@ -71,7 +71,7 @@ export default function page() {
))}
- {t("home.settings.other.select_liraries_you_want_to_hide")}
+ {t("home.settings.other.select_libraries_you_want_to_hide")}
diff --git a/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx b/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
index 58415127..7c5f38d8 100644
--- a/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
@@ -2,6 +2,7 @@ import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { MediaProvider } from "@/components/settings/MediaContext";
+import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
export default function AudioSubtitlesPage() {
@@ -22,6 +23,7 @@ export default function AudioSubtitlesPage() {
+
diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
index e1c8b56b..8bfa30a4 100644
--- a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
@@ -60,7 +60,7 @@ export default function page() {
))}
- {t("home.settings.other.select_liraries_you_want_to_hide")}
+ {t("home.settings.other.select_libraries_you_want_to_hide")}
);
diff --git a/app/(auth)/(tabs)/(home)/settings/intro/page.tsx b/app/(auth)/(tabs)/(home)/settings/intro/page.tsx
index 4cb43e6d..6e04777b 100644
--- a/app/(auth)/(tabs)/(home)/settings/intro/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/intro/page.tsx
@@ -1,13 +1,13 @@
-import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
+import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { storage } from "@/utils/mmkv";
export default function IntroPage() {
- const router = useRouter();
+ const { showIntro } = useIntroSheet();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
@@ -26,7 +26,7 @@ export default function IntroPage() {
{
- router.push("/intro/page");
+ showIntro();
}}
title={t("home.settings.intro.show_intro")}
/>
diff --git a/app/(auth)/(tabs)/(home)/settings/music/page.tsx b/app/(auth)/(tabs)/(home)/settings/music/page.tsx
new file mode 100644
index 00000000..70467b4d
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/music/page.tsx
@@ -0,0 +1,251 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useQuery } from "@tanstack/react-query";
+import { useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, ScrollView, View } from "react-native";
+import { Switch } from "react-native-gesture-handler";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { toast } from "sonner-native";
+import { Text } from "@/components/common/Text";
+import { ListGroup } from "@/components/list/ListGroup";
+import { ListItem } from "@/components/list/ListItem";
+import { PlatformDropdown } from "@/components/PlatformDropdown";
+import { useHaptic } from "@/hooks/useHaptic";
+import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
+import {
+ clearCache,
+ clearPermanentDownloads,
+ getStorageStats,
+} from "@/providers/AudioStorage";
+import { useSettings } from "@/utils/atoms/settings";
+
+const CACHE_SIZE_OPTIONS = [
+ { label: "100 MB", value: 100 },
+ { label: "250 MB", value: 250 },
+ { label: "500 MB", value: 500 },
+ { label: "1 GB", value: 1024 },
+ { label: "2 GB", value: 2048 },
+];
+
+const LOOKAHEAD_COUNT_OPTIONS = [
+ { label: "1 song", value: 1 },
+ { label: "2 songs", value: 2 },
+ { label: "3 songs", value: 3 },
+ { label: "5 songs", value: 5 },
+];
+
+export default function MusicSettingsPage() {
+ const insets = useSafeAreaInsets();
+ const { settings, updateSettings, pluginSettings } = useSettings();
+ const { t } = useTranslation();
+ const queryClient = useNetworkAwareQueryClient();
+ const successHapticFeedback = useHaptic("success");
+ const errorHapticFeedback = useHaptic("error");
+
+ const { data: musicCacheStats } = useQuery({
+ queryKey: ["musicCacheStats"],
+ queryFn: () => getStorageStats(),
+ });
+
+ const onClearMusicCacheClicked = useCallback(async () => {
+ try {
+ await clearCache();
+ queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
+ queryClient.invalidateQueries({ queryKey: ["appSize"] });
+ successHapticFeedback();
+ toast.success(t("home.settings.storage.music_cache_cleared"));
+ } catch (_e) {
+ errorHapticFeedback();
+ toast.error(t("home.settings.toasts.error_deleting_files"));
+ }
+ }, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
+
+ const onDeleteDownloadedSongsClicked = useCallback(async () => {
+ try {
+ await clearPermanentDownloads();
+ queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
+ queryClient.invalidateQueries({ queryKey: ["appSize"] });
+ successHapticFeedback();
+ toast.success(t("home.settings.storage.downloaded_songs_deleted"));
+ } catch (_e) {
+ errorHapticFeedback();
+ toast.error(t("home.settings.toasts.error_deleting_files"));
+ }
+ }, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
+
+ const cacheSizeOptions = useMemo(
+ () => [
+ {
+ options: CACHE_SIZE_OPTIONS.map((option) => ({
+ type: "radio" as const,
+ label: option.label,
+ value: String(option.value),
+ selected: option.value === settings?.audioMaxCacheSizeMB,
+ onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
+ })),
+ },
+ ],
+ [settings?.audioMaxCacheSizeMB, updateSettings],
+ );
+
+ const currentCacheSizeLabel =
+ CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
+ ?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
+
+ const lookaheadCountOptions = useMemo(
+ () => [
+ {
+ options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
+ type: "radio" as const,
+ label: option.label,
+ value: String(option.value),
+ selected: option.value === settings?.audioLookaheadCount,
+ onPress: () => updateSettings({ audioLookaheadCount: option.value }),
+ })),
+ },
+ ],
+ [settings?.audioLookaheadCount, updateSettings],
+ );
+
+ const currentLookaheadLabel =
+ LOOKAHEAD_COUNT_OPTIONS.find(
+ (o) => o.value === settings?.audioLookaheadCount,
+ )?.label ?? `${settings?.audioLookaheadCount} songs`;
+
+ return (
+
+
+
+ {t("home.settings.music.playback_description")}
+
+ }
+ >
+
+
+ updateSettings({ preferLocalAudio: value })
+ }
+ />
+
+
+
+
+
+ {t("home.settings.music.caching_description")}
+
+ }
+ >
+
+
+ updateSettings({ audioLookaheadEnabled: value })
+ }
+ />
+
+
+
+
+ {currentLookaheadLabel}
+
+
+
+ }
+ title={t("home.settings.music.lookahead_count")}
+ />
+
+
+
+
+ {currentCacheSizeLabel}
+
+
+
+ }
+ title={t("home.settings.music.max_cache_size")}
+ />
+
+
+
+
+ {!Platform.isTV && (
+
+
+ {t("home.settings.storage.music_cache_description")}
+
+ }
+ >
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/network/page.tsx b/app/(auth)/(tabs)/(home)/settings/network/page.tsx
new file mode 100644
index 00000000..9f3727e4
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/network/page.tsx
@@ -0,0 +1,48 @@
+import { useAtomValue } from "jotai";
+import { useTranslation } from "react-i18next";
+import { Platform, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { ListGroup } from "@/components/list/ListGroup";
+import { ListItem } from "@/components/list/ListItem";
+import { LocalNetworkSettings } from "@/components/settings/LocalNetworkSettings";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { storage } from "@/utils/mmkv";
+
+export default function NetworkSettingsPage() {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const api = useAtomValue(apiAtom);
+
+ const remoteUrl = storage.getString("serverUrl");
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx
similarity index 78%
rename from app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx
rename to app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx
index cd1efca5..05056826 100644
--- a/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx
@@ -1,7 +1,7 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
-import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
+import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
@@ -17,10 +17,10 @@ export default function page() {
}}
>
-
+
);
diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
index 4eb36aef..10be4af5 100644
--- a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
@@ -1,4 +1,3 @@
-import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -16,6 +15,7 @@ import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
+import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
@@ -26,7 +26,7 @@ export default function page() {
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
- const queryClient = useQueryClient();
+ const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState(settings?.marlinServerUrl || "");
@@ -60,7 +60,7 @@ export default function page() {
),
});
}
- }, [navigation, value]);
+ }, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
if (!settings) return null;
@@ -75,7 +75,10 @@ export default function page() {
{
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/seerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/seerr/page.tsx
new file mode 100644
index 00000000..070c6468
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/plugins/seerr/page.tsx
@@ -0,0 +1,27 @@
+import { ScrollView } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import DisabledSetting from "@/components/settings/DisabledSetting";
+import { SeerrSettings } from "@/components/settings/Seerr";
+import { useSettings } from "@/utils/atoms/settings";
+
+export default function Page() {
+ const { pluginSettings } = useSettings();
+ const insets = useSafeAreaInsets();
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
new file mode 100644
index 00000000..697db6c4
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
@@ -0,0 +1,262 @@
+import { useNavigation } from "expo-router";
+import { useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Linking,
+ ScrollView,
+ Switch,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { toast } from "sonner-native";
+import { Text } from "@/components/common/Text";
+import { ListGroup } from "@/components/list/ListGroup";
+import { ListItem } from "@/components/list/ListItem";
+import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
+import { useSettings } from "@/utils/atoms/settings";
+
+export default function page() {
+ const { t } = useTranslation();
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+
+ const {
+ settings,
+ updateSettings,
+ pluginSettings,
+ refreshStreamyfinPluginSettings,
+ } = useSettings();
+ const queryClient = useNetworkAwareQueryClient();
+
+ // Local state for all editable fields
+ const [url, setUrl] = useState(settings?.streamyStatsServerUrl || "");
+ const [useForSearch, setUseForSearch] = useState(
+ settings?.searchEngine === "Streamystats",
+ );
+ const [movieRecs, setMovieRecs] = useState(
+ settings?.streamyStatsMovieRecommendations ?? false,
+ );
+ const [seriesRecs, setSeriesRecs] = useState(
+ settings?.streamyStatsSeriesRecommendations ?? false,
+ );
+ const [promotedWatchlists, setPromotedWatchlists] = useState(
+ settings?.streamyStatsPromotedWatchlists ?? false,
+ );
+ const [hideWatchlistsTab, setHideWatchlistsTab] = useState(
+ settings?.hideWatchlistsTab ?? false,
+ );
+
+ const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
+ const isStreamystatsEnabled = !!url;
+
+ const onSave = useCallback(() => {
+ const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
+ updateSettings({
+ streamyStatsServerUrl: cleanUrl,
+ searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
+ streamyStatsMovieRecommendations: movieRecs,
+ streamyStatsSeriesRecommendations: seriesRecs,
+ streamyStatsPromotedWatchlists: promotedWatchlists,
+ hideWatchlistsTab: hideWatchlistsTab,
+ });
+ queryClient.invalidateQueries({ queryKey: ["search"] });
+ queryClient.invalidateQueries({ queryKey: ["streamystats"] });
+ toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
+ }, [
+ url,
+ useForSearch,
+ movieRecs,
+ seriesRecs,
+ promotedWatchlists,
+ hideWatchlistsTab,
+ updateSettings,
+ queryClient,
+ t,
+ ]);
+
+ // Set up header save button
+ useEffect(() => {
+ navigation.setOptions({
+ headerRight: () => (
+
+
+ {t("home.settings.plugins.streamystats.save")}
+
+
+ ),
+ });
+ }, [navigation, onSave, t]);
+
+ const handleClearStreamystats = useCallback(() => {
+ setUrl("");
+ setUseForSearch(false);
+ setMovieRecs(false);
+ setSeriesRecs(false);
+ setPromotedWatchlists(false);
+ setHideWatchlistsTab(false);
+ updateSettings({
+ streamyStatsServerUrl: "",
+ searchEngine: "Jellyfin",
+ streamyStatsMovieRecommendations: false,
+ streamyStatsSeriesRecommendations: false,
+ streamyStatsPromotedWatchlists: false,
+ hideWatchlistsTab: false,
+ });
+ queryClient.invalidateQueries({ queryKey: ["streamystats"] });
+ queryClient.invalidateQueries({ queryKey: ["search"] });
+ toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
+ }, [updateSettings, queryClient, t]);
+
+ const handleOpenLink = () => {
+ Linking.openURL("https://github.com/fredrikburmester/streamystats");
+ };
+
+ const handleRefreshFromServer = useCallback(async () => {
+ const newPluginSettings = await refreshStreamyfinPluginSettings(true);
+ // Update local state with new values
+ const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
+ setUrl(newUrl);
+ if (newUrl) {
+ setUseForSearch(true);
+ }
+ toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
+ }, [refreshStreamyfinPluginSettings, t]);
+
+ if (!settings) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
+
+ {t(
+ "home.settings.plugins.streamystats.read_more_about_streamystats",
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("home.settings.plugins.streamystats.home_sections_hint")}
+
+
+
+
+ {t("home.settings.plugins.streamystats.refresh_from_server")}
+
+
+
+ {/* Disable button - only show if URL is not locked and Streamystats is enabled */}
+ {!isUrlLocked && isStreamystatsEnabled && (
+
+
+ {t("home.settings.plugins.streamystats.disable_streamystats")}
+
+
+ )}
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
deleted file mode 100644
index 2636d5c0..00000000
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { Image } from "expo-image";
-import { useLocalSearchParams, useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import type React from "react";
-import { useEffect, useMemo } from "react";
-import { useTranslation } from "react-i18next";
-import { Platform, View } from "react-native";
-import { AddToFavorites } from "@/components/AddToFavorites";
-import { DownloadItems } from "@/components/DownloadItem";
-import { ParallaxScrollView } from "@/components/ParallaxPage";
-import { NextUp } from "@/components/series/NextUp";
-import { SeasonPicker } from "@/components/series/SeasonPicker";
-import { SeriesHeader } from "@/components/series/SeriesHeader";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
-import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
-import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
-
-const page: React.FC = () => {
- const navigation = useNavigation();
- const { t } = useTranslation();
- const params = useLocalSearchParams();
- const { id: seriesId, seasonIndex } = params as {
- id: string;
- seasonIndex: string;
- };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const { data: item } = useQuery({
- queryKey: ["series", seriesId],
- queryFn: async () =>
- await getUserItemData({
- api,
- userId: user?.Id,
- itemId: seriesId,
- }),
- staleTime: 60 * 1000,
- });
-
- const backdropUrl = useMemo(
- () =>
- getBackdropUrl({
- api,
- item,
- quality: 90,
- width: 1000,
- }),
- [item],
- );
-
- const logoUrl = useMemo(
- () =>
- getLogoImageUrlById({
- api,
- item,
- }),
- [item],
- );
-
- const { data: allEpisodes, isLoading } = useQuery({
- queryKey: ["AllEpisodes", item?.Id],
- queryFn: async () => {
- if (!api || !user?.Id || !item?.Id) return [];
-
- const res = await getTvShowsApi(api).getEpisodes({
- seriesId: item.Id,
- userId: user.Id,
- enableUserData: true,
- // Note: Including trick play is necessary to enable trick play downloads
- fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
- });
- return res?.data.Items || [];
- },
- select: (data) =>
- // This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
- [...(data || [])].sort(
- (a, b) =>
- (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
- (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
- ),
- staleTime: 60,
- enabled: !!api && !!user?.Id && !!item?.Id,
- });
-
- useEffect(() => {
- navigation.setOptions({
- headerRight: () =>
- !isLoading &&
- item &&
- allEpisodes &&
- allEpisodes.length > 0 && (
-
-
- {!Platform.isTV && (
- (
-
- )}
- DownloadedIconComponent={() => (
-
- )}
- />
- )}
-
- ),
- });
- }, [allEpisodes, isLoading, item]);
-
- if (!item || !backdropUrl) return null;
-
- return (
-
- }
- logo={
- logoUrl ? (
-
- ) : undefined
- }
- >
-
-
-
-
-
-
-
-
- );
-};
-
-export default page;
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
similarity index 100%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx
similarity index 50%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx
index b3d905fa..d6107217 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx
@@ -13,6 +13,7 @@ import Animated, {
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery";
+import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
const Page: React.FC = () => {
const { id } = useLocalSearchParams() as { id: string };
@@ -21,14 +22,16 @@ const Page: React.FC = () => {
const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true";
- const { data: item, isError } = useItemQuery(id, false, undefined, [
+ // Exclude MediaSources/MediaStreams from initial fetch for faster loading
+ // (especially important for plugins like Gelato)
+ const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
ItemFields.MediaSources,
ItemFields.MediaSourceCount,
ItemFields.MediaStreams,
]);
- // preload media sources
- const { data: itemWithSources } = useItemQuery(id, false, undefined, []);
+ // Lazily preload item with full media sources in background
+ const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
@@ -73,39 +76,35 @@ const Page: React.FC = () => {
);
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {item && (
-
- )}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {item && }
+
+
);
};
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx
similarity index 100%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx
similarity index 100%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx
similarity index 100%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx
similarity index 100%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx
similarity index 100%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx
new file mode 100644
index 00000000..1d1b7620
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx
@@ -0,0 +1,300 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import { useAtom } from "jotai";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ Dimensions,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
+import { MusicTrackItem } from "@/components/music/MusicTrackItem";
+import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
+import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
+import {
+ downloadTrack,
+ isPermanentlyDownloaded,
+} from "@/providers/AudioStorage";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { runtimeTicksToMinutes } from "@/utils/time";
+
+const { width: SCREEN_WIDTH } = Dimensions.get("window");
+const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
+
+export default function AlbumDetailScreen() {
+ const { albumId } = useLocalSearchParams<{ albumId: string }>();
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const insets = useSafeAreaInsets();
+ const navigation = useNavigation();
+ const { t } = useTranslation();
+ const { playQueue } = useMusicPlayer();
+
+ const [selectedTrack, setSelectedTrack] = useState(null);
+ const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
+ const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
+ const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
+ setSelectedTrack(track);
+ setTrackOptionsOpen(true);
+ }, []);
+
+ const handleAddToPlaylist = useCallback(() => {
+ setPlaylistPickerOpen(true);
+ }, []);
+
+ const handleCreateNewPlaylist = useCallback(() => {
+ setCreatePlaylistOpen(true);
+ }, []);
+
+ const { data: album, isLoading: loadingAlbum } = useQuery({
+ queryKey: ["music-album", albumId, user?.Id],
+ queryFn: async () => {
+ const response = await getUserLibraryApi(api!).getItem({
+ userId: user?.Id,
+ itemId: albumId!,
+ });
+ return response.data;
+ },
+ enabled: !!api && !!user?.Id && !!albumId,
+ });
+
+ const { data: tracks, isLoading: loadingTracks } = useQuery({
+ queryKey: ["music-album-tracks", albumId, user?.Id],
+ queryFn: async () => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: albumId,
+ sortBy: ["IndexNumber"],
+ sortOrder: ["Ascending"],
+ });
+ return response.data.Items || [];
+ },
+ enabled: !!api && !!user?.Id && !!albumId,
+ });
+
+ useEffect(() => {
+ navigation.setOptions({
+ title: album?.Name ?? "",
+ headerTransparent: true,
+ headerStyle: { backgroundColor: "transparent" },
+ headerShadowVisible: false,
+ });
+ }, [album?.Name, navigation]);
+
+ const imageUrl = useMemo(
+ () => (album ? getPrimaryImageUrl({ api, item: album }) : null),
+ [api, album],
+ );
+
+ const totalDuration = useMemo(() => {
+ if (!tracks) return "";
+ const totalTicks = tracks.reduce(
+ (acc, track) => acc + (track.RunTimeTicks || 0),
+ 0,
+ );
+ return runtimeTicksToMinutes(totalTicks);
+ }, [tracks]);
+
+ const handlePlayAll = useCallback(() => {
+ if (tracks && tracks.length > 0) {
+ playQueue(tracks, 0);
+ }
+ }, [playQueue, tracks]);
+
+ const handleShuffle = useCallback(() => {
+ if (tracks && tracks.length > 0) {
+ const shuffled = [...tracks].sort(() => Math.random() - 0.5);
+ playQueue(shuffled, 0);
+ }
+ }, [playQueue, tracks]);
+
+ // Check if all tracks are already permanently downloaded
+ const allTracksDownloaded = useMemo(() => {
+ if (!tracks || tracks.length === 0) return false;
+ return tracks.every((track) => isPermanentlyDownloaded(track.Id));
+ }, [tracks]);
+
+ const handleDownloadAlbum = useCallback(async () => {
+ if (!tracks || !api || !user?.Id || isDownloading) return;
+
+ setIsDownloading(true);
+ try {
+ for (const track of tracks) {
+ if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
+ const result = await getAudioStreamUrl(api, user.Id, track.Id);
+ if (result?.url && !result.isTranscoding) {
+ await downloadTrack(track.Id, result.url, {
+ permanent: true,
+ container: result.mediaSource?.Container || undefined,
+ });
+ }
+ }
+ } catch {
+ // Silent fail
+ }
+ setIsDownloading(false);
+ }, [tracks, api, user?.Id, isDownloading]);
+
+ const isLoading = loadingAlbum || loadingTracks;
+
+ // Only show loading if we have no cached data to display
+ if (isLoading && !album) {
+ return (
+
+
+
+ );
+ }
+
+ if (!album) {
+ return (
+
+ {t("music.album_not_found")}
+
+ );
+ }
+
+ return (
+
+ {/* Album artwork */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Album info */}
+
+ {album.Name}
+
+
+ {album.AlbumArtist || album.Artists?.join(", ")}
+
+
+ {album.ProductionYear && `${album.ProductionYear} โข `}
+ {tracks?.length} tracks โข {totalDuration}
+
+
+ {/* Play buttons */}
+
+
+
+
+ {t("music.play")}
+
+
+
+
+
+ {t("music.shuffle")}
+
+
+
+ {isDownloading ? (
+
+ ) : (
+
+ )}
+
+
+
+ }
+ renderItem={({ item, index }) => (
+
+ )}
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ <>
+
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx
new file mode 100644
index 00000000..fe2631e9
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx
@@ -0,0 +1,273 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import { useAtom } from "jotai";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Dimensions, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { HorizontalScroll } from "@/components/common/HorizontalScroll";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
+import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
+import { MusicTrackItem } from "@/components/music/MusicTrackItem";
+import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
+import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+const { width: SCREEN_WIDTH } = Dimensions.get("window");
+const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
+
+export default function ArtistDetailScreen() {
+ const { artistId } = useLocalSearchParams<{ artistId: string }>();
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const insets = useSafeAreaInsets();
+ const navigation = useNavigation();
+ const { t } = useTranslation();
+ const { playQueue } = useMusicPlayer();
+
+ const [selectedTrack, setSelectedTrack] = useState(null);
+ const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
+ const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
+ const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
+
+ const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
+ setSelectedTrack(track);
+ setTrackOptionsOpen(true);
+ }, []);
+
+ const handleAddToPlaylist = useCallback(() => {
+ setPlaylistPickerOpen(true);
+ }, []);
+
+ const handleCreateNewPlaylist = useCallback(() => {
+ setCreatePlaylistOpen(true);
+ }, []);
+
+ const { data: artist, isLoading: loadingArtist } = useQuery({
+ queryKey: ["music-artist", artistId, user?.Id],
+ queryFn: async () => {
+ const response = await getUserLibraryApi(api!).getItem({
+ userId: user?.Id,
+ itemId: artistId!,
+ });
+ return response.data;
+ },
+ enabled: !!api && !!user?.Id && !!artistId,
+ });
+
+ const { data: albums, isLoading: loadingAlbums } = useQuery({
+ queryKey: ["music-artist-albums", artistId, user?.Id],
+ queryFn: async () => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ artistIds: [artistId!],
+ includeItemTypes: ["MusicAlbum"],
+ sortBy: ["ProductionYear", "SortName"],
+ sortOrder: ["Descending", "Ascending"],
+ recursive: true,
+ });
+ return response.data.Items || [];
+ },
+ enabled: !!api && !!user?.Id && !!artistId,
+ });
+
+ const { data: topTracks, isLoading: loadingTracks } = useQuery({
+ queryKey: ["music-artist-top-tracks", artistId, user?.Id],
+ queryFn: async () => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ artistIds: [artistId!],
+ includeItemTypes: ["Audio"],
+ sortBy: ["PlayCount"],
+ sortOrder: ["Descending"],
+ limit: 10,
+ recursive: true,
+ filters: ["IsPlayed"],
+ });
+ return response.data.Items || [];
+ },
+ enabled: !!api && !!user?.Id && !!artistId,
+ });
+
+ useEffect(() => {
+ navigation.setOptions({
+ title: artist?.Name ?? "",
+ headerTransparent: true,
+ headerStyle: { backgroundColor: "transparent" },
+ headerShadowVisible: false,
+ });
+ }, [artist?.Name, navigation]);
+
+ const imageUrl = useMemo(
+ () => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
+ [api, artist],
+ );
+
+ const handlePlayAllTracks = useCallback(() => {
+ if (topTracks && topTracks.length > 0) {
+ playQueue(topTracks, 0);
+ }
+ }, [playQueue, topTracks]);
+
+ const isLoading = loadingArtist || loadingAlbums || loadingTracks;
+
+ // Only show loading if we have no cached data to display
+ if (isLoading && !artist) {
+ return (
+
+
+
+ );
+ }
+
+ if (!artist) {
+ return (
+
+ {t("music.artist_not_found")}
+
+ );
+ }
+
+ const sections = [];
+
+ // Top tracks section
+ if (topTracks && topTracks.length > 0) {
+ sections.push({
+ id: "top-tracks",
+ title: t("music.top_tracks"),
+ type: "tracks" as const,
+ data: topTracks,
+ });
+ }
+
+ // Albums section
+ if (albums && albums.length > 0) {
+ sections.push({
+ id: "albums",
+ title: t("music.tabs.albums"),
+ type: "albums" as const,
+ data: albums,
+ });
+ }
+
+ return (
+
+ {/* Artist image */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Artist info */}
+
+ {artist.Name}
+
+
+ {albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
+
+
+ {/* Play button */}
+ {topTracks && topTracks.length > 0 && (
+
+
+
+ {t("music.play_top_tracks")}
+
+
+ )}
+
+ }
+ renderItem={({ item: section }) => (
+
+ {section.title}
+ {section.type === "albums" ? (
+ item.Id!}
+ renderItem={(item) => }
+ />
+ ) : (
+ section.data
+ .slice(0, 5)
+ .map((track, index) => (
+
+ ))
+ )}
+
+ )}
+ keyExtractor={(item) => item.id}
+ ListFooterComponent={
+ <>
+
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx
new file mode 100644
index 00000000..346e40aa
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx
@@ -0,0 +1,315 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import { useAtom } from "jotai";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ActivityIndicator, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
+import { MusicTrackItem } from "@/components/music/MusicTrackItem";
+import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
+import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
+import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
+import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
+import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { runtimeTicksToMinutes } from "@/utils/time";
+
+const ARTWORK_SIZE = 120;
+
+export default function PlaylistDetailScreen() {
+ const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const insets = useSafeAreaInsets();
+ const navigation = useNavigation();
+ const { t } = useTranslation();
+ const { playQueue } = useMusicPlayer();
+
+ const [selectedTrack, setSelectedTrack] = useState(null);
+ const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
+ const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
+ const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
+ const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const removeFromPlaylist = useRemoveFromPlaylist();
+
+ const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
+ setSelectedTrack(track);
+ setTrackOptionsOpen(true);
+ }, []);
+
+ const handleAddToPlaylist = useCallback(() => {
+ setPlaylistPickerOpen(true);
+ }, []);
+
+ const handleCreateNewPlaylist = useCallback(() => {
+ setCreatePlaylistOpen(true);
+ }, []);
+
+ const handleRemoveFromPlaylist = useCallback(() => {
+ if (selectedTrack?.Id && playlistId) {
+ removeFromPlaylist.mutate({
+ playlistId,
+ entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
+ });
+ }
+ }, [selectedTrack, playlistId, removeFromPlaylist]);
+
+ const { data: playlist, isLoading: loadingPlaylist } = useQuery({
+ queryKey: ["music-playlist", playlistId, user?.Id],
+ queryFn: async () => {
+ const response = await getUserLibraryApi(api!).getItem({
+ userId: user?.Id,
+ itemId: playlistId!,
+ });
+ return response.data;
+ },
+ enabled: !!api && !!user?.Id && !!playlistId,
+ });
+
+ const { data: tracks, isLoading: loadingTracks } = useQuery({
+ queryKey: ["music-playlist-tracks", playlistId, user?.Id],
+ queryFn: async () => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: playlistId,
+ });
+ return response.data.Items || [];
+ },
+ enabled: !!api && !!user?.Id && !!playlistId,
+ });
+
+ useEffect(() => {
+ navigation.setOptions({
+ title: playlist?.Name ?? "",
+ headerTransparent: true,
+ headerStyle: { backgroundColor: "transparent" },
+ headerShadowVisible: false,
+ headerRight: () => (
+ setPlaylistOptionsOpen(true)}
+ className='p-1.5'
+ >
+
+
+ ),
+ });
+ }, [playlist?.Name, navigation]);
+
+ const imageUrl = useMemo(
+ () => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
+ [api, playlist],
+ );
+
+ const totalDuration = useMemo(() => {
+ if (!tracks) return "";
+ const totalTicks = tracks.reduce(
+ (acc, track) => acc + (track.RunTimeTicks || 0),
+ 0,
+ );
+ return runtimeTicksToMinutes(totalTicks);
+ }, [tracks]);
+
+ const handlePlayAll = useCallback(() => {
+ if (tracks && tracks.length > 0) {
+ playQueue(tracks, 0);
+ }
+ }, [playQueue, tracks]);
+
+ const handleShuffle = useCallback(() => {
+ if (tracks && tracks.length > 0) {
+ const shuffled = [...tracks].sort(() => Math.random() - 0.5);
+ playQueue(shuffled, 0);
+ }
+ }, [playQueue, tracks]);
+
+ // Check if all tracks are already downloaded
+ const allTracksDownloaded = useMemo(() => {
+ if (!tracks || tracks.length === 0) return false;
+ return tracks.every((track) => !!getLocalPath(track.Id));
+ }, [tracks]);
+
+ const handleDownloadPlaylist = useCallback(async () => {
+ if (!tracks || !api || !user?.Id || isDownloading) return;
+
+ setIsDownloading(true);
+ try {
+ for (const track of tracks) {
+ if (!track.Id || getLocalPath(track.Id)) continue;
+ const result = await getAudioStreamUrl(api, user.Id, track.Id);
+ if (result?.url && !result.isTranscoding) {
+ await downloadTrack(track.Id, result.url, {
+ permanent: true,
+ container: result.mediaSource?.Container || undefined,
+ });
+ }
+ }
+ } catch {
+ // Silent fail
+ }
+ setIsDownloading(false);
+ }, [tracks, api, user?.Id, isDownloading]);
+
+ const isLoading = loadingPlaylist || loadingTracks;
+
+ // Only show loading if we have no cached data to display
+ if (isLoading && !playlist) {
+ return (
+
+
+
+ );
+ }
+
+ if (!playlist) {
+ return (
+
+
+ {t("music.playlist_not_found")}
+
+
+ );
+ }
+
+ return (
+
+ {/* Playlist artwork */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Playlist info */}
+
+ {playlist.Name}
+
+
+ {tracks?.length} tracks โข {totalDuration}
+
+
+ {/* Play buttons */}
+
+
+
+
+ {t("music.play")}
+
+
+
+
+
+ {t("music.shuffle")}
+
+
+
+ {isDownloading ? (
+
+ ) : (
+
+ )}
+
+
+
+ }
+ renderItem={({ item, index }) => (
+
+ )}
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ <>
+
+
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/persons/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx
similarity index 100%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/persons/[personId].tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/company/[companyId].tsx
similarity index 69%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/company/[companyId].tsx
index cd9cc3cc..359ebfd1 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/company/[companyId].tsx
@@ -3,9 +3,9 @@ import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
-import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
-import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
-import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
+import SeerrPoster from "@/components/posters/SeerrPoster";
+import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
+import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
@@ -13,34 +13,33 @@ import {
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
-export default function page() {
+export default function CompanyPage() {
const local = useLocalSearchParams();
- const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
+ const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { companyId, image, type } = local as unknown as {
companyId: string;
name: string;
image: string;
- type: DiscoverSliderType;
+ type: DiscoverSliderType; //This gets converted to a string because it's a url param
};
- const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
- queryKey: ["jellyseerr", "company", type, companyId],
+ const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
+ queryKey: ["seerr", "company", type, companyId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
};
-
- return jellyseerrApi?.discover(
+ return seerrApi?.discover(
`${
- type === DiscoverSliderType.NETWORKS
+ Number(type) === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
}/${companyId}`,
params,
);
},
- enabled: !!jellyseerrApi && !!companyId,
+ enabled: !!seerrApi && !!companyId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -54,8 +53,7 @@ export default function page() {
data?.pages
?.filter((p) => p?.results.length)
.flatMap(
- (p) =>
- p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
+ (p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
@@ -64,15 +62,15 @@ export default function page() {
const backdrops = useMemo(
() =>
- jellyseerrApi
+ seerrApi
? flatData.map((r) =>
- jellyseerrApi.imageProxy(
+ seerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
- [jellyseerrApi, flatData],
+ [seerrApi, flatData],
);
return (
@@ -86,13 +84,14 @@ export default function page() {
fetchNextPage();
}
}}
+ isLoading={isLoading}
logo={
}
- renderItem={(item, _index) => }
+ renderItem={(item, _index) => }
/>
);
}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/genre/[genreId].tsx
similarity index 67%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/genre/[genreId].tsx
index 7ea00808..a61cff40 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/genre/[genreId].tsx
@@ -3,15 +3,15 @@ import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import { Text } from "@/components/common/Text";
-import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
-import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
-import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
-import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
+import SeerrPoster from "@/components/posters/SeerrPoster";
+import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
+import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
+import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
-export default function page() {
+export default function GenrePage() {
const local = useLocalSearchParams();
- const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
+ const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { genreId, name, type } = local as unknown as {
genreId: string;
@@ -20,21 +20,21 @@ export default function page() {
};
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
- queryKey: ["jellyseerr", "company", type, genreId],
+ queryKey: ["seerr", "company", type, genreId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
genre: genreId,
};
- return jellyseerrApi?.discover(
+ return seerrApi?.discover(
type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params,
);
},
- enabled: !!jellyseerrApi && !!genreId,
+ enabled: !!seerrApi && !!genreId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -48,8 +48,7 @@ export default function page() {
data?.pages
?.filter((p) => p?.results.length)
.flatMap(
- (p) =>
- p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
+ (p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
@@ -58,15 +57,12 @@ export default function page() {
const backdrops = useMemo(
() =>
- jellyseerrApi
+ seerrApi
? flatData.map((r) =>
- jellyseerrApi.imageProxy(
- r.backdropPath,
- "w1920_and_h800_multi_faces",
- ),
+ seerrApi.imageProxy(r.backdropPath, "w1920_and_h800_multi_faces"),
)
: [],
- [jellyseerrApi, flatData],
+ [seerrApi, flatData],
);
return (
@@ -91,7 +87,7 @@ export default function page() {
{name}
}
- renderItem={(item, _index) => }
+ renderItem={(item, _index) => }
/>
);
}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page.tsx
similarity index 87%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page.tsx
index c8ab71ab..901f5032 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page.tsx
@@ -8,7 +8,7 @@ import {
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
-import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
+import { useLocalSearchParams, useNavigation } from "expo-router";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -18,17 +18,18 @@ import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
-import Cast from "@/components/jellyseerr/Cast";
-import DetailFacts from "@/components/jellyseerr/DetailFacts";
-import RequestModal from "@/components/jellyseerr/RequestModal";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown";
-import { JellyserrRatings } from "@/components/Ratings";
-import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
+import { SeerrRatings } from "@/components/Ratings";
+import Cast from "@/components/seerr/Cast";
+import DetailFacts from "@/components/seerr/DetailFacts";
+import RequestModal from "@/components/seerr/RequestModal";
+import SeerrSeasons from "@/components/series/SeerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
-import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
+import useRouter from "@/hooks/useAppRouter";
+import { useSeerr } from "@/hooks/useSeerr";
+import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {
type IssueType,
@@ -67,7 +68,7 @@ const Page: React.FC = () => {
} & Partial;
const navigation = useNavigation();
- const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
+ const { seerrApi, seerrUser, requestMedia } = useSeerr();
const [issueType, setIssueType] = useState();
const [issueMessage, setIssueMessage] = useState();
@@ -82,8 +83,8 @@ const Page: React.FC = () => {
isLoading,
refetch,
} = useQuery({
- enabled: !!jellyseerrApi && !!result && !!result.id,
- queryKey: ["jellyseerr", "detail", mediaType, result.id],
+ enabled: !!seerrApi && !!result && !!result.id,
+ queryKey: ["seerr", "detail", mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
@@ -92,21 +93,18 @@ const Page: React.FC = () => {
refetchInterval: 0,
queryFn: async () => {
return mediaType === MediaType.MOVIE
- ? jellyseerrApi?.movieDetails(result.id!)
- : jellyseerrApi?.tvDetails(result.id!);
+ ? seerrApi?.movieDetails(result.id!)
+ : seerrApi?.tvDetails(result.id!);
},
});
const [canRequest, hasAdvancedRequestPermission] =
- useJellyseerrCanRequest(details);
+ useSeerrCanRequest(details);
const canManageRequests = useMemo(() => {
- if (!jellyseerrUser) return false;
- return hasPermission(
- Permission.MANAGE_REQUESTS,
- jellyseerrUser.permissions,
- );
- }, [jellyseerrUser]);
+ if (!seerrUser) return false;
+ return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions);
+ }, [seerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
@@ -118,27 +116,27 @@ const Page: React.FC = () => {
if (!pendingRequest?.id) return;
try {
- await jellyseerrApi?.approveRequest(pendingRequest.id);
- toast.success(t("jellyseerr.toasts.request_approved"));
+ await seerrApi?.approveRequest(pendingRequest.id);
+ toast.success(t("seerr.toasts.request_approved"));
refetch();
} catch (error) {
- toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
+ toast.error(t("seerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error);
}
- }, [jellyseerrApi, pendingRequest, refetch, t]);
+ }, [seerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
- await jellyseerrApi?.declineRequest(pendingRequest.id);
- toast.success(t("jellyseerr.toasts.request_declined"));
+ await seerrApi?.declineRequest(pendingRequest.id);
+ toast.success(t("seerr.toasts.request_declined"));
refetch();
} catch (error) {
- toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
+ toast.error(t("seerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error);
}
- }, [jellyseerrApi, pendingRequest, refetch, t]);
+ }, [seerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -153,7 +151,7 @@ const Page: React.FC = () => {
const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) {
- jellyseerrApi
+ seerrApi
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
.then(() => {
setIssueType(undefined);
@@ -161,7 +159,7 @@ const Page: React.FC = () => {
bottomSheetModalRef?.current?.close();
});
}
- }, [jellyseerrApi, details, result, issueType, issueMessage]);
+ }, [seerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false);
@@ -213,7 +211,7 @@ const Page: React.FC = () => {
const issueTypeOptionGroups = useMemo(
() => [
{
- title: t("jellyseerr.types"),
+ title: t("seerr.types"),
options: Object.entries(IssueTypeName)
.reverse()
.map(([key, value]) => ({
@@ -264,7 +262,7 @@ const Page: React.FC = () => {
height: "100%",
}}
source={{
- uri: jellyseerrApi?.imageProxy(
+ uri: seerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces",
),
@@ -294,7 +292,7 @@ const Page: React.FC = () => {
- {
/>
) : canRequest ? (
) : (
details?.mediaInfo?.jellyfinMediaId && (
@@ -352,7 +350,7 @@ const Page: React.FC = () => {
}}
>
- {t("jellyseerr.report_issue_button")}
+ {t("seerr.report_issue_button")}
)}
@@ -388,12 +386,12 @@ const Page: React.FC = () => {
- {t("jellyseerr.requested_by", {
+ {t("seerr.requested_by", {
user:
pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername ||
- t("jellyseerr.unknown_user"),
+ t("seerr.unknown_user"),
})}
@@ -414,7 +412,7 @@ const Page: React.FC = () => {
borderStyle: "solid",
}}
>
- {t("jellyseerr.approve")}
+ {t("seerr.approve")}
@@ -441,7 +439,7 @@ const Page: React.FC = () => {
{mediaType === MediaType.TV && (
- {
- {t("jellyseerr.whats_wrong")}
+ {t("seerr.whats_wrong")}
- {t("jellyseerr.issue_type")}
+ {t("seerr.issue_type")}
{
{issueType
? IssueTypeName[issueType]
- : t("jellyseerr.select_an_issue")}
+ : t("seerr.select_an_issue")}
}
- title={t("jellyseerr.types")}
+ title={t("seerr.types")}
open={issueTypeDropdownOpen}
onOpenChange={setIssueTypeDropdownOpen}
/>
@@ -521,7 +519,7 @@ const Page: React.FC = () => {
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
- placeholder={t("jellyseerr.describe_the_issue")}
+ placeholder={t("seerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
@@ -531,7 +529,7 @@ const Page: React.FC = () => {
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/person/[personId].tsx
similarity index 69%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/person/[personId].tsx
index 0bfb35be..fd44bff4 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/person/[personId].tsx
@@ -5,31 +5,27 @@ import { orderBy, uniqBy } from "lodash";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@/components/common/Text";
-import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import { OverviewText } from "@/components/OverviewText";
-import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
+import SeerrPoster from "@/components/posters/SeerrPoster";
+import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
+import { useSeerr } from "@/hooks/useSeerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
-export default function page() {
+export default function PersonPage() {
const local = useLocalSearchParams();
const { t } = useTranslation();
- const {
- jellyseerrApi,
- jellyseerrRegion: region,
- jellyseerrLocale: locale,
- } = useJellyseerr();
+ const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
const { personId } = local as { personId: string };
const { data } = useQuery({
- queryKey: ["jellyseerr", "person", personId],
+ queryKey: ["seerr", "person", personId],
queryFn: async () => ({
- details: await jellyseerrApi?.personDetails(personId),
- combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
+ details: await seerrApi?.personDetails(personId),
+ combinedCredits: await seerrApi?.personCombinedCredits(personId),
}),
- enabled: !!jellyseerrApi && !!personId,
+ enabled: !!seerrApi && !!personId,
});
const castedRoles: PersonCreditCast[] = useMemo(
@@ -46,22 +42,19 @@ export default function page() {
);
const backdrops = useMemo(
() =>
- jellyseerrApi
+ seerrApi
? castedRoles.map((c) =>
- jellyseerrApi.imageProxy(
- c.backdropPath,
- "w1920_and_h800_multi_faces",
- ),
+ seerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"),
)
: [],
- [jellyseerrApi, data?.combinedCredits],
+ [seerrApi, data?.combinedCredits],
);
return (
item.id.toString()}
logo={
{data?.details?.name}
- {t("jellyseerr.born")}{" "}
+ {t("seerr.born")}{" "}
{data?.details?.birthday &&
new Date(data.details.birthday).toLocaleDateString(locale, {
year: "numeric",
@@ -100,7 +93,7 @@ export default function page() {
MainContent={() => (
)}
- renderItem={(item, _index) => }
+ renderItem={(item, _index) => }
/>
);
}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
new file mode 100644
index 00000000..9c2d3f48
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
@@ -0,0 +1,216 @@
+import { Ionicons } from "@expo/vector-icons";
+import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import { useAtom } from "jotai";
+import type React from "react";
+import { useEffect, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, View } from "react-native";
+import { AddToFavorites } from "@/components/AddToFavorites";
+import { DownloadItems } from "@/components/DownloadItem";
+import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { NextUp } from "@/components/series/NextUp";
+import { SeasonPicker } from "@/components/series/SeasonPicker";
+import { SeriesHeader } from "@/components/series/SeriesHeader";
+import { useDownload } from "@/providers/DownloadProvider";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
+import {
+ buildOfflineSeriesFromEpisodes,
+ getDownloadedEpisodesForSeries,
+} from "@/utils/downloads/offline-series";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { storage } from "@/utils/mmkv";
+
+const page: React.FC = () => {
+ const navigation = useNavigation();
+ const { t } = useTranslation();
+ const params = useLocalSearchParams();
+ const {
+ id: seriesId,
+ seasonIndex,
+ offline: offlineParam,
+ } = params as {
+ id: string;
+ seasonIndex: string;
+ offline?: string;
+ };
+
+ const isOffline = offlineParam === "true";
+
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const { getDownloadedItems, downloadedItems } = useDownload();
+
+ // For offline mode, construct series data from downloaded episodes
+ // Include downloadedItems.length so query refetches when items are deleted
+ const { data: item } = useQuery({
+ queryKey: ["series", seriesId, isOffline, downloadedItems.length],
+ queryFn: async () => {
+ if (isOffline) {
+ return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
+ }
+ return await getUserItemData({
+ api,
+ userId: user?.Id,
+ itemId: seriesId,
+ });
+ },
+ staleTime: isOffline ? Infinity : 60 * 1000,
+ enabled: isOffline || (!!api && !!user?.Id),
+ });
+
+ // For offline mode, use stored base64 image
+ const base64Image = useMemo(() => {
+ if (isOffline) {
+ return storage.getString(seriesId);
+ }
+ return null;
+ }, [isOffline, seriesId]);
+
+ const backdropUrl = useMemo(() => {
+ if (isOffline && base64Image) {
+ return `data:image/jpeg;base64,${base64Image}`;
+ }
+ return getBackdropUrl({
+ api,
+ item,
+ quality: 90,
+ width: 1000,
+ });
+ }, [isOffline, base64Image, api, item]);
+
+ const logoUrl = useMemo(() => {
+ if (isOffline) {
+ return null; // No logo in offline mode
+ }
+ return getLogoImageUrlById({
+ api,
+ item,
+ });
+ }, [isOffline, api, item]);
+
+ const { data: allEpisodes, isLoading } = useQuery({
+ queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
+ queryFn: async () => {
+ if (isOffline) {
+ return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
+ }
+ if (!api || !user?.Id) return [];
+
+ const res = await getTvShowsApi(api).getEpisodes({
+ seriesId: seriesId,
+ userId: user.Id,
+ enableUserData: true,
+ fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
+ });
+ return res?.data.Items || [];
+ },
+ select: (data) =>
+ [...(data || [])].sort(
+ (a, b) =>
+ (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
+ (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
+ ),
+ staleTime: isOffline ? Infinity : 60,
+ enabled: isOffline || (!!api && !!user?.Id),
+ });
+
+ useEffect(() => {
+ // Don't show header buttons in offline mode
+ if (isOffline) {
+ navigation.setOptions({
+ headerRight: () => null,
+ });
+ return;
+ }
+
+ navigation.setOptions({
+ headerRight: () =>
+ !isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
+
+
+ {!Platform.isTV && (
+ (
+
+ )}
+ DownloadedIconComponent={() => (
+
+ )}
+ />
+ )}
+
+ ) : null,
+ });
+ }, [allEpisodes, isLoading, item, isOffline]);
+
+ // For offline mode, we can show the page even without backdropUrl
+ if (!item || (!isOffline && !backdropUrl)) return null;
+
+ return (
+
+
+ ) : (
+
+ )
+ }
+ logo={
+ logoUrl ? (
+
+ ) : undefined
+ }
+ >
+
+
+ {!isOffline && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default page;
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index 481881fc..d96bf5b5 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -2,6 +2,7 @@ import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
+ ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -27,7 +28,11 @@ import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
+ FilterByOption,
+ FilterByPreferenceAtom,
+ filterByAtom,
genreFilterAtom,
+ getFilterByPreference,
getSortByPreference,
getSortOrderPreference,
SortByOption,
@@ -39,12 +44,19 @@ import {
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
+ useFilterOptions,
yearFilterAtom,
} from "@/utils/atoms/filters";
+import { useSettings } from "@/utils/atoms/settings";
const Page = () => {
- const searchParams = useLocalSearchParams();
- const { libraryId } = searchParams as { libraryId: string };
+ const searchParams = useLocalSearchParams() as {
+ libraryId: string;
+ sortBy?: string;
+ sortOrder?: string;
+ filterBy?: string;
+ };
+ const { libraryId } = searchParams;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -54,9 +66,13 @@ const Page = () => {
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom);
+ const [filterBy, _setFilterBy] = useAtom(filterByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
- const [sortOrderPreference, setOderByPreference] = useAtom(
+ const [filterByPreference, setFilterByPreference] = useAtom(
+ FilterByPreferenceAtom,
+ );
+ const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom,
);
@@ -65,17 +81,33 @@ const Page = () => {
const { t } = useTranslation();
useEffect(() => {
- const sop = getSortOrderPreference(libraryId, sortOrderPreference);
- if (sop) {
- _setSortOrder([sop]);
+ // Check for URL params first (from "See All" navigation)
+ const urlSortBy = searchParams.sortBy as SortByOption | undefined;
+ const urlSortOrder = searchParams.sortOrder as SortOrderOption | undefined;
+ const urlFilterBy = searchParams.filterBy as FilterByOption | undefined;
+
+ // Apply sortOrder: URL param > saved preference > default
+ if (urlSortOrder && Object.values(SortOrderOption).includes(urlSortOrder)) {
+ _setSortOrder([urlSortOrder]);
} else {
- _setSortOrder([SortOrderOption.Ascending]);
+ const sop = getSortOrderPreference(libraryId, sortOrderPreference);
+ _setSortOrder([sop || SortOrderOption.Ascending]);
}
- const obp = getSortByPreference(libraryId, sortByPreference);
- if (obp) {
- _setSortBy([obp]);
+
+ // Apply sortBy: URL param > saved preference > default
+ if (urlSortBy && Object.values(SortByOption).includes(urlSortBy)) {
+ _setSortBy([urlSortBy]);
} else {
- _setSortBy([SortByOption.SortName]);
+ const obp = getSortByPreference(libraryId, sortByPreference);
+ _setSortBy([obp || SortByOption.SortName]);
+ }
+
+ // Apply filterBy: URL param > saved preference > default
+ if (urlFilterBy && Object.values(FilterByOption).includes(urlFilterBy)) {
+ _setFilterBy([urlFilterBy]);
+ } else {
+ const fp = getFilterByPreference(libraryId, filterByPreference);
+ _setFilterBy(fp ? [fp] : []);
}
}, [
libraryId,
@@ -83,6 +115,11 @@ const Page = () => {
sortByPreference,
_setSortOrder,
_setSortBy,
+ filterByPreference,
+ _setFilterBy,
+ searchParams.sortBy,
+ searchParams.sortOrder,
+ searchParams.filterBy,
]);
const setSortBy = useCallback(
@@ -100,14 +137,28 @@ const Page = () => {
(sortOrder: SortOrderOption[]) => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sortOrder[0] !== sop) {
- setOderByPreference({
+ setOrderByPreference({
...sortOrderPreference,
[libraryId]: sortOrder[0],
});
}
_setSortOrder(sortOrder);
},
- [libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
+ [libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
+ );
+
+ const setFilter = useCallback(
+ (filterBy: FilterByOption[]) => {
+ const fp = getFilterByPreference(libraryId, filterByPreference);
+ if (filterBy[0] !== fp) {
+ setFilterByPreference({
+ ...filterByPreference,
+ [libraryId]: filterBy[0],
+ });
+ }
+ _setFilterBy(filterBy);
+ },
+ [libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
);
const nrOfCols = useMemo(() => {
@@ -158,6 +209,10 @@ const Page = () => {
itemType = "Series";
} else if (library.CollectionType === "boxsets") {
itemType = "BoxSet";
+ } else if (library.CollectionType === "homevideos") {
+ itemType = "Video";
+ } else if (library.CollectionType === "musicvideos") {
+ itemType = "MusicVideo";
}
const response = await getItemsApi(api).getItems({
@@ -168,6 +223,7 @@ const Page = () => {
sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
+ filters: filterBy as ItemFilter[],
// true is needed for merged versions
recursive: true,
imageTypeLimit: 1,
@@ -190,6 +246,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
+ filterBy,
],
);
@@ -203,6 +260,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
+ filterBy,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
@@ -268,7 +326,8 @@ const Page = () => {
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
-
+ const generalFilters = useFilterOptions();
+ const settings = useSettings();
const ListHeaderComponent = useCallback(
() => (
{
/>
),
},
+ {
+ key: "filterOptions",
+ component: (
+ generalFilters.map((s) => s.key)}
+ set={setFilter}
+ values={filterBy}
+ title={t("library.filters.filter_by")}
+ renderItemLabel={(item) =>
+ generalFilters.find((i) => i.key === item)?.value || ""
+ }
+ searchFilter={(item, search) =>
+ item.toLowerCase().includes(search.toLowerCase())
+ }
+ />
+ ),
+ },
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
@@ -424,6 +503,9 @@ const Page = () => {
sortOrder,
setSortOrder,
isFetching,
+ filterBy,
+ setFilter,
+ settings,
],
);
diff --git a/app/(auth)/(tabs)/(libraries)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx
index b56397f9..37b89bbf 100644
--- a/app/(auth)/(tabs)/(libraries)/index.tsx
+++ b/app/(auth)/(tabs)/(libraries)/index.tsx
@@ -39,7 +39,6 @@ export default function index() {
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
- .filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
);
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
new file mode 100644
index 00000000..69daf929
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
@@ -0,0 +1,85 @@
+import {
+ createMaterialTopTabNavigator,
+ MaterialTopTabNavigationEventMap,
+ MaterialTopTabNavigationOptions,
+} from "@react-navigation/material-top-tabs";
+import type {
+ ParamListBase,
+ TabNavigationState,
+} from "@react-navigation/native";
+import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
+import { useTranslation } from "react-i18next";
+
+const { Navigator } = createMaterialTopTabNavigator();
+
+const TAB_LABEL_FONT_SIZE = 13;
+const TAB_ITEM_HORIZONTAL_PADDING = 12;
+
+export const Tab = withLayoutContext<
+ MaterialTopTabNavigationOptions,
+ typeof Navigator,
+ TabNavigationState,
+ MaterialTopTabNavigationEventMap
+>(Navigator);
+
+const Layout = () => {
+ const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Layout;
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
new file mode 100644
index 00000000..3fb5305b
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
@@ -0,0 +1,120 @@
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useRoute } from "@react-navigation/native";
+import { FlashList } from "@shopify/flash-list";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useLocalSearchParams } from "expo-router";
+import { useAtom } from "jotai";
+import { useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { RefreshControl, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+const ITEMS_PER_PAGE = 40;
+
+export default function AlbumsScreen() {
+ const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
+ const route = useRoute();
+ const libraryId =
+ (Array.isArray(localParams.libraryId)
+ ? localParams.libraryId[0]
+ : localParams.libraryId) ?? route?.params?.libraryId;
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+
+ const {
+ data,
+ isLoading,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ refetch,
+ } = useInfiniteQuery({
+ queryKey: ["music-albums", libraryId, user?.Id],
+ queryFn: async ({ pageParam = 0 }) => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: libraryId,
+ includeItemTypes: ["MusicAlbum"],
+ sortBy: ["SortName"],
+ sortOrder: ["Ascending"],
+ limit: ITEMS_PER_PAGE,
+ startIndex: pageParam,
+ recursive: true,
+ });
+ return {
+ items: response.data.Items || [],
+ totalCount: response.data.TotalRecordCount || 0,
+ startIndex: pageParam,
+ };
+ },
+ getNextPageParam: (lastPage) => {
+ const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
+ return nextStart < lastPage.totalCount ? nextStart : undefined;
+ },
+ initialPageParam: 0,
+ enabled: !!api && !!user?.Id && !!libraryId,
+ });
+
+ const albums = useMemo(() => {
+ return data?.pages.flatMap((page) => page.items) || [];
+ }, [data]);
+
+ const handleEndReached = useCallback(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (albums.length === 0) {
+ return (
+
+ {t("music.no_albums")}
+
+ );
+ }
+
+ return (
+
+
+ }
+ onEndReached={handleEndReached}
+ onEndReachedThreshold={0.5}
+ renderItem={({ item }) => }
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ isFetchingNextPage ? (
+
+
+
+ ) : null
+ }
+ />
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
new file mode 100644
index 00000000..e8191404
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
@@ -0,0 +1,157 @@
+import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useRoute } from "@react-navigation/native";
+import { FlashList } from "@shopify/flash-list";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useLocalSearchParams } from "expo-router";
+import { useAtom } from "jotai";
+import { useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { RefreshControl, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { MusicArtistCard } from "@/components/music/MusicArtistCard";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+// Web uses Limit=100
+const ITEMS_PER_PAGE = 100;
+
+export default function ArtistsScreen() {
+ const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
+ const route = useRoute();
+ const libraryId =
+ (Array.isArray(localParams.libraryId)
+ ? localParams.libraryId[0]
+ : localParams.libraryId) ?? route?.params?.libraryId;
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+
+ const isReady = Boolean(api && user?.Id && libraryId);
+
+ const {
+ data,
+ isLoading,
+ isError,
+ error,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ refetch,
+ } = useInfiniteQuery({
+ queryKey: ["music-artists", libraryId, user?.Id],
+ queryFn: async ({ pageParam = 0 }) => {
+ const response = await getArtistsApi(api!).getArtists({
+ userId: user?.Id,
+ parentId: libraryId,
+ sortBy: ["SortName"],
+ sortOrder: ["Ascending"],
+ fields: ["PrimaryImageAspectRatio", "SortName"],
+ imageTypeLimit: 1,
+ enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
+ limit: ITEMS_PER_PAGE,
+ startIndex: pageParam,
+ });
+ return {
+ items: response.data.Items || [],
+ totalCount: response.data.TotalRecordCount || 0,
+ startIndex: pageParam,
+ };
+ },
+ getNextPageParam: (lastPage) => {
+ const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
+ return nextStart < lastPage.totalCount ? nextStart : undefined;
+ },
+ initialPageParam: 0,
+ enabled: isReady,
+ });
+
+ const artists = useMemo(() => {
+ return data?.pages.flatMap((page) => page.items) || [];
+ }, [data]);
+
+ const handleEndReached = useCallback(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ if (!api || !user?.Id) {
+ return (
+
+
+
+ );
+ }
+
+ if (!libraryId) {
+ return (
+
+
+ Missing music library id.
+
+
+ );
+ }
+
+ // Only show loading if we have no cached data to display
+ if (isLoading && artists.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // Only show error if we have no cached data to display
+ // This allows offline access to previously cached artists
+ if (isError && artists.length === 0) {
+ return (
+
+
+ Failed to load artists: {(error as Error)?.message || "Unknown error"}
+
+
+ );
+ }
+
+ if (artists.length === 0) {
+ return (
+
+ {t("music.no_artists")}
+
+ );
+ }
+
+ return (
+
+
+ }
+ onEndReached={handleEndReached}
+ onEndReachedThreshold={0.5}
+ renderItem={({ item }) => }
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ isFetchingNextPage ? (
+
+
+
+ ) : null
+ }
+ />
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
new file mode 100644
index 00000000..a03f9c3d
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
@@ -0,0 +1,234 @@
+import { Ionicons } from "@expo/vector-icons";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useNavigation, useRoute } from "@react-navigation/native";
+import { FlashList } from "@shopify/flash-list";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useLocalSearchParams } from "expo-router";
+import { useAtom } from "jotai";
+import { useCallback, useLayoutEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { RefreshControl, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
+import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
+import {
+ type PlaylistSortOption,
+ type PlaylistSortOrder,
+ PlaylistSortSheet,
+} from "@/components/music/PlaylistSortSheet";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+const ITEMS_PER_PAGE = 40;
+
+export default function PlaylistsScreen() {
+ const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
+ const route = useRoute();
+ const navigation = useNavigation();
+ const libraryId =
+ (Array.isArray(localParams.libraryId)
+ ? localParams.libraryId[0]
+ : localParams.libraryId) ?? route?.params?.libraryId;
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+ const [sortSheetOpen, setSortSheetOpen] = useState(false);
+ const [sortBy, setSortBy] = useState("SortName");
+ const [sortOrder, setSortOrder] = useState("Ascending");
+
+ const isReady = Boolean(api && user?.Id && libraryId);
+
+ const handleSortChange = useCallback(
+ (newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
+ setSortBy(newSortBy);
+ setSortOrder(newSortOrder);
+ },
+ [],
+ );
+
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ headerRight: () => (
+ setCreateModalOpen(true)}
+ className='mr-4'
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+ ),
+ });
+ }, [navigation]);
+
+ const {
+ data,
+ isLoading,
+ isError,
+ error,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ refetch,
+ } = useInfiniteQuery({
+ queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder],
+ queryFn: async ({ pageParam = 0 }) => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ includeItemTypes: ["Playlist"],
+ sortBy: [sortBy],
+ sortOrder: [sortOrder],
+ limit: ITEMS_PER_PAGE,
+ startIndex: pageParam,
+ recursive: true,
+ mediaTypes: ["Audio"],
+ });
+ return {
+ items: response.data.Items || [],
+ totalCount: response.data.TotalRecordCount || 0,
+ startIndex: pageParam,
+ };
+ },
+ getNextPageParam: (lastPage) => {
+ const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
+ return nextStart < lastPage.totalCount ? nextStart : undefined;
+ },
+ initialPageParam: 0,
+ enabled: isReady,
+ });
+
+ const playlists = useMemo(() => {
+ return data?.pages.flatMap((page) => page.items) || [];
+ }, [data]);
+
+ const handleEndReached = useCallback(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ if (!api || !user?.Id) {
+ return (
+
+
+
+ );
+ }
+
+ if (!libraryId) {
+ return (
+
+
+ Missing music library id.
+
+
+ );
+ }
+
+ // Only show loading if we have no cached data to display
+ if (isLoading && playlists.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // Only show error if we have no cached data to display
+ // This allows offline access to previously cached playlists
+ if (isError && playlists.length === 0) {
+ return (
+
+
+ Failed to load playlists:{" "}
+ {(error as Error)?.message || "Unknown error"}
+
+
+ );
+ }
+
+ if (playlists.length === 0) {
+ return (
+
+ {t("music.no_playlists")}
+ setCreateModalOpen(true)}
+ className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
+ >
+
+
+ {t("music.playlists.create_playlist")}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ }
+ onEndReached={handleEndReached}
+ onEndReachedThreshold={0.5}
+ ListHeaderComponent={
+ setSortSheetOpen(true)}
+ className='flex-row items-center mb-2 py-1'
+ >
+
+
+ {t(
+ `music.sort.${sortBy === "SortName" ? "alphabetical" : "date_created"}`,
+ )}
+
+
+
+ }
+ renderItem={({ item }) => }
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ isFetchingNextPage ? (
+
+
+
+ ) : null
+ }
+ />
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
new file mode 100644
index 00000000..fb762862
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
@@ -0,0 +1,333 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useRoute } from "@react-navigation/native";
+import { FlashList } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
+import { useLocalSearchParams } from "expo-router";
+import { useAtom } from "jotai";
+import { useCallback, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { RefreshControl, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { HorizontalScroll } from "@/components/common/HorizontalScroll";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
+import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
+import { MusicTrackItem } from "@/components/music/MusicTrackItem";
+import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
+import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { writeDebugLog } from "@/utils/log";
+
+export default function SuggestionsScreen() {
+ const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
+ const route = useRoute();
+ const libraryId =
+ (Array.isArray(localParams.libraryId)
+ ? localParams.libraryId[0]
+ : localParams.libraryId) ?? route?.params?.libraryId;
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+
+ const [selectedTrack, setSelectedTrack] = useState(null);
+ const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
+ const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
+ const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
+
+ const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
+ setSelectedTrack(track);
+ setTrackOptionsOpen(true);
+ }, []);
+
+ const handleAddToPlaylist = useCallback(() => {
+ setPlaylistPickerOpen(true);
+ }, []);
+
+ const handleCreateNewPlaylist = useCallback(() => {
+ setCreatePlaylistOpen(true);
+ }, []);
+
+ const isReady = Boolean(api && user?.Id && libraryId);
+
+ writeDebugLog("Music suggestions params", {
+ libraryId,
+ localParams,
+ routeParams: route?.params,
+ isReady,
+ });
+
+ // Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
+ // This returns the most recently added albums
+ const {
+ data: latestAlbums,
+ isLoading: loadingLatest,
+ isError: isLatestError,
+ error: latestError,
+ refetch: refetchLatest,
+ } = useQuery({
+ queryKey: ["music-latest", libraryId, user?.Id],
+ queryFn: async () => {
+ // Prefer the exact endpoint the Web client calls (HAR):
+ // /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
+ // IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
+ const res = await api!.get(
+ `/Users/${user!.Id}/Items/Latest`,
+ {
+ params: {
+ IncludeItemTypes: "Audio",
+ Limit: 20,
+ Fields: "PrimaryImageAspectRatio",
+ ParentId: libraryId,
+ ImageTypeLimit: 1,
+ EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
+ EnableTotalRecordCount: false,
+ },
+ },
+ );
+
+ if (Array.isArray(res.data) && res.data.length > 0) {
+ return res.data;
+ }
+
+ // Fallback: ask for albums directly via /Items (more reliable across server variants)
+ const fallback = await getItemsApi(api!).getItems({
+ userId: user!.Id,
+ parentId: libraryId,
+ includeItemTypes: ["MusicAlbum"],
+ sortBy: ["DateCreated"],
+ sortOrder: ["Descending"],
+ limit: 20,
+ recursive: true,
+ fields: ["PrimaryImageAspectRatio", "SortName"],
+ imageTypeLimit: 1,
+ enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
+ enableTotalRecordCount: false,
+ });
+ return fallback.data.Items || [];
+ },
+ enabled: isReady,
+ });
+
+ // Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
+ const {
+ data: recentlyPlayed,
+ isLoading: loadingRecentlyPlayed,
+ isError: isRecentlyPlayedError,
+ error: recentlyPlayedError,
+ refetch: refetchRecentlyPlayed,
+ } = useQuery({
+ queryKey: ["music-recently-played", libraryId, user?.Id],
+ queryFn: async () => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: libraryId,
+ includeItemTypes: ["Audio"],
+ sortBy: ["DatePlayed"],
+ sortOrder: ["Descending"],
+ limit: 10,
+ recursive: true,
+ fields: ["PrimaryImageAspectRatio", "SortName"],
+ filters: ["IsPlayed"],
+ imageTypeLimit: 1,
+ enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
+ enableTotalRecordCount: false,
+ });
+ return response.data.Items || [];
+ },
+ enabled: isReady,
+ });
+
+ // Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
+ const {
+ data: frequentlyPlayed,
+ isLoading: loadingFrequent,
+ isError: isFrequentError,
+ error: frequentError,
+ refetch: refetchFrequent,
+ } = useQuery({
+ queryKey: ["music-frequently-played", libraryId, user?.Id],
+ queryFn: async () => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: libraryId,
+ includeItemTypes: ["Audio"],
+ sortBy: ["PlayCount"],
+ sortOrder: ["Descending"],
+ limit: 10,
+ recursive: true,
+ fields: ["PrimaryImageAspectRatio", "SortName"],
+ filters: ["IsPlayed"],
+ imageTypeLimit: 1,
+ enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
+ enableTotalRecordCount: false,
+ });
+ return response.data.Items || [];
+ },
+ enabled: isReady,
+ });
+
+ const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
+
+ const handleRefresh = useCallback(() => {
+ refetchLatest();
+ refetchRecentlyPlayed();
+ refetchFrequent();
+ }, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
+
+ const sections = useMemo(() => {
+ const result: {
+ title: string;
+ data: BaseItemDto[];
+ type: "albums" | "tracks";
+ }[] = [];
+
+ // Latest albums section
+ if (latestAlbums && latestAlbums.length > 0) {
+ result.push({
+ title: t("music.recently_added"),
+ data: latestAlbums,
+ type: "albums",
+ });
+ }
+
+ // Recently played tracks
+ if (recentlyPlayed && recentlyPlayed.length > 0) {
+ result.push({
+ title: t("music.recently_played"),
+ data: recentlyPlayed,
+ type: "tracks",
+ });
+ }
+
+ // Frequently played tracks
+ if (frequentlyPlayed && frequentlyPlayed.length > 0) {
+ result.push({
+ title: t("music.frequently_played"),
+ data: frequentlyPlayed,
+ type: "tracks",
+ });
+ }
+
+ return result;
+ }, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
+
+ if (!api || !user?.Id) {
+ return (
+
+
+
+ );
+ }
+
+ if (!libraryId) {
+ return (
+
+
+ Missing music library id.
+
+
+ );
+ }
+
+ // Only show loading if we have no cached data to display
+ if (isLoading && sections.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // Only show error if we have no cached data to display
+ // This allows offline access to previously cached suggestions
+ if (
+ (isLatestError || isRecentlyPlayedError || isFrequentError) &&
+ sections.length === 0
+ ) {
+ const msg =
+ (latestError as Error | undefined)?.message ||
+ (recentlyPlayedError as Error | undefined)?.message ||
+ (frequentError as Error | undefined)?.message ||
+ "Unknown error";
+ return (
+
+
+ Failed to load music: {msg}
+
+
+ );
+ }
+
+ if (sections.length === 0) {
+ return (
+
+ {t("music.no_suggestions")}
+
+ );
+ }
+
+ return (
+
+
+ }
+ renderItem={({ item: section }) => (
+
+ {section.title}
+ {section.type === "albums" ? (
+ item.Id!}
+ renderItem={(item) => }
+ />
+ ) : (
+ section.data
+ .slice(0, 5)
+ .map((track, index, _tracks) => (
+
+ ))
+ )}
+
+ )}
+ keyExtractor={(item) => item.title}
+ />
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx
index f4d34978..b008c04d 100644
--- a/app/(auth)/(tabs)/(search)/_layout.tsx
+++ b/app/(auth)/(tabs)/(search)/_layout.tsx
@@ -33,17 +33,17 @@ export default function SearchLayout() {
headerShadowVisible: false,
}}
/>
-
+
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index bedf5ffa..cff07dd4 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -3,9 +3,11 @@ import type {
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
-import { router, useLocalSearchParams, useNavigation } from "expo-router";
+import { Image } from "expo-image";
+import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import {
useCallback,
@@ -19,26 +21,28 @@ import {
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useDebounce } from "use-debounce";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
-import {
- JellyseerrSearchSort,
- JellyserrIndexPage,
-} from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
+import {
+ SeerrIndexPage,
+ SeerrSearchSort,
+} from "@/components/seerr/SeerrIndexPage";
+import useRouter from "@/hooks/useAppRouter";
+import { useSeerr } from "@/hooks/useSeerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover";
@@ -51,9 +55,10 @@ const exampleSearches = [
"The Mandalorian",
];
-export default function search() {
+export default function SearchPage() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
+ const router = useRouter();
const [user] = useAtom(userAtom);
@@ -67,21 +72,32 @@ export default function search() {
const [searchType, setSearchType] = useState("Library");
const [search, setSearch] = useState("");
- const [debouncedSearch] = useDebounce(search, 500);
+ const [debouncedSearch, setDebouncedSearch] = useState("");
+ const abortControllerRef = useRef(null);
+
+ const searchDebouncer = useAsyncDebouncer(
+ async (query: string) => {
+ // Cancel previous in-flight requests
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = new AbortController();
+ setDebouncedSearch(query);
+ return query;
+ },
+ { wait: 200 },
+ );
+
+ useEffect(() => {
+ searchDebouncer.maybeExecute(search);
+ }, [search]);
const [api] = useAtom(apiAtom);
const { settings } = useSettings();
- const { jellyseerrApi } = useJellyseerr();
- const [jellyseerrOrderBy, setJellyseerrOrderBy] =
- useState(
- JellyseerrSearchSort[
- JellyseerrSearchSort.DEFAULT
- ] as unknown as JellyseerrSearchSort,
- );
- const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
- "asc" | "desc"
- >("desc");
+ const { seerrApi } = useSeerr();
+ const [seerrOrderBy, setSeerrOrderBy] = useState(
+ SeerrSearchSort[SeerrSearchSort.DEFAULT] as unknown as SeerrSearchSort,
+ );
+ const [seerrSortOrder, setSeerrSortOrder] = useState<"asc" | "desc">("desc");
const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin";
@@ -97,9 +113,11 @@ export default function search() {
async ({
types,
query,
+ signal,
}: {
types: BaseItemKind[];
query: string;
+ signal?: AbortSignal;
}): Promise => {
if (!api || !query) {
return [];
@@ -107,16 +125,71 @@ export default function search() {
try {
if (searchEngine === "Jellyfin") {
- const searchApi = await getItemsApi(api).getItems({
- searchTerm: query,
- limit: 10,
- includeItemTypes: types,
- recursive: true,
- userId: user?.Id,
- });
+ const searchApi = await getItemsApi(api).getItems(
+ {
+ searchTerm: query,
+ limit: 10,
+ includeItemTypes: types,
+ recursive: true,
+ userId: user?.Id,
+ },
+ { signal },
+ );
return (searchApi.data.Items as BaseItemDto[]) || [];
}
+
+ if (searchEngine === "Streamystats") {
+ if (!settings?.streamyStatsServerUrl || !api.accessToken) {
+ return [];
+ }
+
+ const streamyStatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const typeMap: Record = {
+ Movie: "movies",
+ Series: "series",
+ Episode: "episodes",
+ Person: "actors",
+ BoxSet: "movies",
+ Audio: "audio",
+ } as Record;
+
+ const searchType = types.length === 1 ? typeMap[types[0]] : "media";
+ const response = await streamyStatsApi.searchIds(
+ query,
+ searchType as "movies" | "series" | "episodes" | "actors" | "media",
+ 10,
+ signal,
+ );
+
+ const allIds: string[] = [
+ ...(response.data.movies || []),
+ ...(response.data.series || []),
+ ...(response.data.episodes || []),
+ ...(response.data.actors || []),
+ ...(response.data.audio || []),
+ ];
+
+ if (!allIds.length) {
+ return [];
+ }
+
+ const itemsResponse = await getItemsApi(api).getItems(
+ {
+ ids: allIds,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ },
+ { signal },
+ );
+
+ return (itemsResponse.data.Items as BaseItemDto[]) || [];
+ }
+
+ // Marlin search
if (!settings?.marlinServerUrl) {
return [];
}
@@ -127,7 +200,7 @@ export default function search() {
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
- const response1 = await axios.get(url);
+ const response1 = await axios.get(url, { signal });
const ids = response1.data.ids;
@@ -135,18 +208,63 @@ export default function search() {
return [];
}
- const response2 = await getItemsApi(api).getItems({
- ids,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
- });
+ const response2 = await getItemsApi(api).getItems(
+ {
+ ids,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ },
+ { signal },
+ );
return (response2.data.Items as BaseItemDto[]) || [];
} catch (error) {
- console.error("Error during search:", error);
- return []; // Ensure an empty array is returned in case of an error
+ // Silently handle aborted requests
+ if (error instanceof Error && error.name === "AbortError") {
+ return [];
+ }
+ return [];
}
},
- [api, searchEngine, settings],
+ [api, searchEngine, settings, user?.Id],
+ );
+
+ // Separate search function for music types - always uses Jellyfin since Streamystats doesn't support music
+ const jellyfinSearchFn = useCallback(
+ async ({
+ types,
+ query,
+ signal,
+ }: {
+ types: BaseItemKind[];
+ query: string;
+ signal?: AbortSignal;
+ }): Promise => {
+ if (!api || !query) {
+ return [];
+ }
+
+ try {
+ const searchApi = await getItemsApi(api).getItems(
+ {
+ searchTerm: query,
+ limit: 10,
+ includeItemTypes: types,
+ recursive: true,
+ userId: user?.Id,
+ },
+ { signal },
+ );
+
+ return (searchApi.data.Items as BaseItemDto[]) || [];
+ } catch (error) {
+ // Silently handle aborted requests
+ if (error instanceof Error && error.name === "AbortError") {
+ return [];
+ }
+ return [];
+ }
+ },
+ [api, user?.Id],
);
type HeaderSearchBarRef = {
@@ -195,6 +313,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Movie"],
+ signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -205,6 +324,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Series"],
+ signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -215,6 +335,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Episode"],
+ signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -225,6 +346,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["BoxSet"],
+ signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -235,6 +357,52 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Person"],
+ signal: abortControllerRef.current?.signal,
+ }),
+ enabled: searchType === "Library" && debouncedSearch.length > 0,
+ });
+
+ // Music search queries - always use Jellyfin since Streamystats doesn't support music
+ const { data: artists, isFetching: l9 } = useQuery({
+ queryKey: ["search", "artists", debouncedSearch],
+ queryFn: () =>
+ jellyfinSearchFn({
+ query: debouncedSearch,
+ types: ["MusicArtist"],
+ signal: abortControllerRef.current?.signal,
+ }),
+ enabled: searchType === "Library" && debouncedSearch.length > 0,
+ });
+
+ const { data: albums, isFetching: l10 } = useQuery({
+ queryKey: ["search", "albums", debouncedSearch],
+ queryFn: () =>
+ jellyfinSearchFn({
+ query: debouncedSearch,
+ types: ["MusicAlbum"],
+ signal: abortControllerRef.current?.signal,
+ }),
+ enabled: searchType === "Library" && debouncedSearch.length > 0,
+ });
+
+ const { data: songs, isFetching: l11 } = useQuery({
+ queryKey: ["search", "songs", debouncedSearch],
+ queryFn: () =>
+ jellyfinSearchFn({
+ query: debouncedSearch,
+ types: ["Audio"],
+ signal: abortControllerRef.current?.signal,
+ }),
+ enabled: searchType === "Library" && debouncedSearch.length > 0,
+ });
+
+ const { data: playlists, isFetching: l12 } = useQuery({
+ queryKey: ["search", "playlists", debouncedSearch],
+ queryFn: () =>
+ jellyfinSearchFn({
+ query: debouncedSearch,
+ types: ["Playlist"],
+ signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -245,13 +413,27 @@ export default function search() {
episodes?.length ||
series?.length ||
collections?.length ||
- actors?.length
+ actors?.length ||
+ artists?.length ||
+ albums?.length ||
+ songs?.length ||
+ playlists?.length
);
- }, [episodes, movies, series, collections, actors]);
+ }, [
+ episodes,
+ movies,
+ series,
+ collections,
+ actors,
+ artists,
+ albums,
+ songs,
+ playlists,
+ ]);
const loading = useMemo(() => {
- return l1 || l2 || l3 || l7 || l8;
- }, [l1, l2, l3, l7, l8]);
+ return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
+ }, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
return (
{/*
- {jellyseerrApi && (
+ {seerrApi && (
)}
@@ -398,12 +581,178 @@ export default function search() {
)}
/>
+ {/* Music search results */}
+ {
+ const imageUrl = getPrimaryImageUrl({ api, item });
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ ๐ค
+
+ )}
+
+
+ {item.Name}
+
+
+ );
+ }}
+ />
+ {
+ const imageUrl = getPrimaryImageUrl({ api, item });
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ ๐ต
+
+ )}
+
+
+ {item.Name}
+
+
+ {item.AlbumArtist || item.Artists?.join(", ")}
+
+
+ );
+ }}
+ />
+ {
+ const imageUrl = getPrimaryImageUrl({ api, item });
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ ๐ต
+
+ )}
+
+
+ {item.Name}
+
+
+ {item.Artists?.join(", ") || item.AlbumArtist}
+
+
+ );
+ }}
+ />
+ {
+ const imageUrl = getPrimaryImageUrl({ api, item });
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ ๐ถ
+
+ )}
+
+
+ {item.Name}
+
+
+ {item.ChildCount} tracks
+
+
+ );
+ }}
+ />
) : (
-
)}
diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
new file mode 100644
index 00000000..831feac7
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
@@ -0,0 +1,298 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { FlashList } from "@shopify/flash-list";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import { useAtomValue } from "jotai";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ Alert,
+ RefreshControl,
+ TouchableOpacity,
+ useWindowDimensions,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { HeaderBackButton } from "@/components/common/HeaderBackButton";
+import { Text } from "@/components/common/Text";
+import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
+import { ItemCardText } from "@/components/ItemCardText";
+import { ItemPoster } from "@/components/posters/ItemPoster";
+import useRouter from "@/hooks/useAppRouter";
+import { useOrientation } from "@/hooks/useOrientation";
+import {
+ useDeleteWatchlist,
+ useRemoveFromWatchlist,
+} from "@/hooks/useWatchlistMutations";
+import {
+ useWatchlistDetailQuery,
+ useWatchlistItemsQuery,
+} from "@/hooks/useWatchlists";
+import * as ScreenOrientation from "@/packages/expo-screen-orientation";
+import { userAtom } from "@/providers/JellyfinProvider";
+
+export default function WatchlistDetailScreen() {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
+ const user = useAtomValue(userAtom);
+ const { width: screenWidth } = useWindowDimensions();
+ const { orientation } = useOrientation();
+
+ const watchlistIdNum = watchlistId
+ ? Number.parseInt(watchlistId, 10)
+ : undefined;
+
+ const nrOfCols = useMemo(() => {
+ if (screenWidth < 300) return 2;
+ if (screenWidth < 500) return 3;
+ if (screenWidth < 800) return 5;
+ if (screenWidth < 1000) return 6;
+ if (screenWidth < 1500) return 7;
+ return 6;
+ }, [screenWidth]);
+
+ const {
+ data: watchlist,
+ isLoading: watchlistLoading,
+ refetch: refetchWatchlist,
+ } = useWatchlistDetailQuery(watchlistIdNum);
+
+ const {
+ data: items,
+ isLoading: itemsLoading,
+ refetch: refetchItems,
+ } = useWatchlistItemsQuery(watchlistIdNum);
+
+ const deleteWatchlist = useDeleteWatchlist();
+ const removeFromWatchlist = useRemoveFromWatchlist();
+ const [refreshing, setRefreshing] = useState(false);
+
+ const isOwner = useMemo(
+ () => watchlist?.userId === user?.Id,
+ [watchlist?.userId, user?.Id],
+ );
+
+ // Set up header
+ useEffect(() => {
+ navigation.setOptions({
+ headerTitle: watchlist?.name || "",
+ headerLeft: () => ,
+ headerRight: isOwner
+ ? () => (
+
+
+ router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
+ }
+ className='p-2'
+ >
+
+
+
+
+
+
+ )
+ : undefined,
+ });
+ }, [navigation, watchlist?.name, isOwner, watchlistId]);
+
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+ await Promise.all([refetchWatchlist(), refetchItems()]);
+ setRefreshing(false);
+ }, [refetchWatchlist, refetchItems]);
+
+ const handleDelete = useCallback(() => {
+ Alert.alert(
+ t("watchlists.delete_confirm_title"),
+ t("watchlists.delete_confirm_message", { name: watchlist?.name }),
+ [
+ { text: t("watchlists.cancel_button"), style: "cancel" },
+ {
+ text: t("watchlists.delete_button"),
+ style: "destructive",
+ onPress: async () => {
+ if (watchlistIdNum) {
+ await deleteWatchlist.mutateAsync(watchlistIdNum);
+ router.back();
+ }
+ },
+ },
+ ],
+ );
+ }, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]);
+
+ const handleRemoveItem = useCallback(
+ (item: BaseItemDto) => {
+ if (!watchlistIdNum || !item.Id) return;
+
+ Alert.alert(
+ t("watchlists.remove_item_title"),
+ t("watchlists.remove_item_message", { name: item.Name }),
+ [
+ { text: t("watchlists.cancel_button"), style: "cancel" },
+ {
+ text: t("watchlists.remove_button"),
+ style: "destructive",
+ onPress: async () => {
+ await removeFromWatchlist.mutateAsync({
+ watchlistId: watchlistIdNum,
+ itemId: item.Id!,
+ watchlistName: watchlist?.name,
+ });
+ },
+ },
+ ],
+ );
+ },
+ [removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
+ );
+
+ const renderItem = useCallback(
+ ({ item, index }: { item: BaseItemDto; index: number }) => (
+ handleRemoveItem(item) : undefined}
+ >
+
+
+
+
+
+ ),
+ [isOwner, handleRemoveItem, orientation, nrOfCols],
+ );
+
+ const ListHeader = useMemo(
+ () =>
+ watchlist ? (
+
+ {watchlist.description && (
+
+ {watchlist.description}
+
+ )}
+
+
+
+
+ {items?.length ?? 0}{" "}
+ {(items?.length ?? 0) === 1
+ ? t("watchlists.item")
+ : t("watchlists.items")}
+
+
+
+
+
+ {watchlist.isPublic
+ ? t("watchlists.public")
+ : t("watchlists.private")}
+
+
+ {!isOwner && (
+
+ {t("watchlists.by_owner")}
+
+ )}
+
+
+ ) : null,
+ [watchlist, items?.length, isOwner, t],
+ );
+
+ const EmptyComponent = useMemo(
+ () => (
+
+
+
+ {t("watchlists.empty_watchlist")}
+
+ {isOwner && (
+
+ {t("watchlists.empty_watchlist_hint")}
+
+ )}
+
+ ),
+ [isOwner, t],
+ );
+
+ const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
+
+ if (watchlistLoading || itemsLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!watchlist) {
+ return (
+
+
+ {t("watchlists.not_found")}
+
+
+ );
+ }
+
+ return (
+
+ }
+ renderItem={renderItem}
+ ItemSeparatorComponent={() => (
+
+ )}
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(watchlists)/_layout.tsx b/app/(auth)/(tabs)/(watchlists)/_layout.tsx
new file mode 100644
index 00000000..807530ac
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/_layout.tsx
@@ -0,0 +1,76 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Stack } from "expo-router";
+import { useTranslation } from "react-i18next";
+import { Platform } from "react-native";
+import { Pressable } from "react-native-gesture-handler";
+import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
+import useRouter from "@/hooks/useAppRouter";
+import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
+
+export default function WatchlistsLayout() {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const streamystatsEnabled = useStreamystatsEnabled();
+
+ return (
+
+ (
+
+ router.push("/(auth)/(tabs)/(watchlists)/create")
+ }
+ className='p-1.5'
+ >
+
+
+ )
+ : undefined,
+ }}
+ />
+
+
+
+ {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
+
+ ))}
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(watchlists)/create.tsx b/app/(auth)/(tabs)/(watchlists)/create.tsx
new file mode 100644
index 00000000..af77d5db
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/create.tsx
@@ -0,0 +1,221 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ Switch,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
+import type {
+ StreamystatsWatchlistAllowedItemType,
+ StreamystatsWatchlistSortOrder,
+} from "@/utils/streamystats/types";
+
+const ITEM_TYPES: Array<{
+ value: StreamystatsWatchlistAllowedItemType;
+ label: string;
+}> = [
+ { value: null, label: "All Types" },
+ { value: "Movie", label: "Movies Only" },
+ { value: "Series", label: "Series Only" },
+ { value: "Episode", label: "Episodes Only" },
+];
+
+const SORT_OPTIONS: Array<{
+ value: StreamystatsWatchlistSortOrder;
+ label: string;
+}> = [
+ { value: "custom", label: "Custom Order" },
+ { value: "name", label: "Name" },
+ { value: "dateAdded", label: "Date Added" },
+ { value: "releaseDate", label: "Release Date" },
+];
+
+export default function CreateWatchlistScreen() {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const createWatchlist = useCreateWatchlist();
+
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [isPublic, setIsPublic] = useState(false);
+ const [allowedItemType, setAllowedItemType] =
+ useState(null);
+ const [defaultSortOrder, setDefaultSortOrder] =
+ useState("custom");
+
+ const handleCreate = useCallback(async () => {
+ if (!name.trim()) return;
+
+ try {
+ await createWatchlist.mutateAsync({
+ name: name.trim(),
+ description: description.trim() || undefined,
+ isPublic,
+ allowedItemType,
+ defaultSortOrder,
+ });
+ router.back();
+ } catch {
+ // Error handled by mutation
+ }
+ }, [
+ name,
+ description,
+ isPublic,
+ allowedItemType,
+ defaultSortOrder,
+ createWatchlist,
+ router,
+ ]);
+
+ return (
+
+
+ {/* Name */}
+
+
+ {t("watchlists.name_label")} *
+
+
+
+
+ {/* Description */}
+
+
+ {t("watchlists.description_label")}
+
+
+
+
+ {/* Public Toggle */}
+
+
+
+ {t("watchlists.is_public_label")}
+
+
+ {t("watchlists.is_public_description")}
+
+
+
+
+
+ {/* Content Type */}
+
+
+ {t("watchlists.allowed_type_label")}
+
+
+ {ITEM_TYPES.map((type) => (
+ setAllowedItemType(type.value)}
+ className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
+ >
+
+ {type.label}
+
+
+ ))}
+
+
+
+ {/* Sort Order */}
+
+
+ {t("watchlists.sort_order_label")}
+
+
+ {SORT_OPTIONS.map((sort) => (
+ setDefaultSortOrder(sort.value)}
+ className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
+ >
+
+ {sort.label}
+
+
+ ))}
+
+
+
+ {/* Create Button */}
+
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
new file mode 100644
index 00000000..5329d6a6
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
@@ -0,0 +1,274 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useLocalSearchParams } from "expo-router";
+import { useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ Switch,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
+import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
+import type {
+ StreamystatsWatchlistAllowedItemType,
+ StreamystatsWatchlistSortOrder,
+} from "@/utils/streamystats/types";
+
+const ITEM_TYPES: Array<{
+ value: StreamystatsWatchlistAllowedItemType;
+ label: string;
+}> = [
+ { value: null, label: "All Types" },
+ { value: "Movie", label: "Movies Only" },
+ { value: "Series", label: "Series Only" },
+ { value: "Episode", label: "Episodes Only" },
+];
+
+const SORT_OPTIONS: Array<{
+ value: StreamystatsWatchlistSortOrder;
+ label: string;
+}> = [
+ { value: "custom", label: "Custom Order" },
+ { value: "name", label: "Name" },
+ { value: "dateAdded", label: "Date Added" },
+ { value: "releaseDate", label: "Release Date" },
+];
+
+export default function EditWatchlistScreen() {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
+ const watchlistIdNum = watchlistId
+ ? Number.parseInt(watchlistId, 10)
+ : undefined;
+
+ const { data: watchlist, isLoading } =
+ useWatchlistDetailQuery(watchlistIdNum);
+ const updateWatchlist = useUpdateWatchlist();
+
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [isPublic, setIsPublic] = useState(false);
+ const [allowedItemType, setAllowedItemType] =
+ useState(null);
+ const [defaultSortOrder, setDefaultSortOrder] =
+ useState("custom");
+
+ // Initialize form with watchlist data
+ useEffect(() => {
+ if (watchlist) {
+ setName(watchlist.name);
+ setDescription(watchlist.description ?? "");
+ setIsPublic(watchlist.isPublic);
+ setAllowedItemType(
+ (watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ??
+ null,
+ );
+ setDefaultSortOrder(
+ (watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ??
+ "custom",
+ );
+ }
+ }, [watchlist]);
+
+ const handleSave = useCallback(async () => {
+ if (!name.trim() || !watchlistIdNum) return;
+
+ try {
+ await updateWatchlist.mutateAsync({
+ watchlistId: watchlistIdNum,
+ data: {
+ name: name.trim(),
+ description: description.trim() || undefined,
+ isPublic,
+ allowedItemType,
+ defaultSortOrder,
+ },
+ });
+ router.back();
+ } catch {
+ // Error handled by mutation
+ }
+ }, [
+ name,
+ description,
+ isPublic,
+ allowedItemType,
+ defaultSortOrder,
+ watchlistIdNum,
+ updateWatchlist,
+ router,
+ ]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!watchlist) {
+ return (
+
+
+ {t("watchlists.not_found")}
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Name */}
+
+
+ {t("watchlists.name_label")} *
+
+
+
+
+ {/* Description */}
+
+
+ {t("watchlists.description_label")}
+
+
+
+
+ {/* Public Toggle */}
+
+
+
+ {t("watchlists.is_public_label")}
+
+
+ {t("watchlists.is_public_description")}
+
+
+
+
+
+ {/* Content Type */}
+
+
+ {t("watchlists.allowed_type_label")}
+
+
+ {ITEM_TYPES.map((type) => (
+ setAllowedItemType(type.value)}
+ className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
+ >
+
+ {type.label}
+
+
+ ))}
+
+
+
+ {/* Sort Order */}
+
+
+ {t("watchlists.sort_order_label")}
+
+
+ {SORT_OPTIONS.map((sort) => (
+ setDefaultSortOrder(sort.value)}
+ className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
+ >
+
+ {sort.label}
+
+
+ ))}
+
+
+
+ {/* Save Button */}
+
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(watchlists)/index.tsx b/app/(auth)/(tabs)/(watchlists)/index.tsx
new file mode 100644
index 00000000..76d446d4
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/index.tsx
@@ -0,0 +1,239 @@
+import { Ionicons } from "@expo/vector-icons";
+import { FlashList } from "@shopify/flash-list";
+import { useAtomValue } from "jotai";
+import { useCallback, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import {
+ useStreamystatsEnabled,
+ useWatchlistsQuery,
+} from "@/hooks/useWatchlists";
+import { userAtom } from "@/providers/JellyfinProvider";
+import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
+
+interface WatchlistCardProps {
+ watchlist: StreamystatsWatchlist;
+ isOwner: boolean;
+ onPress: () => void;
+}
+
+const WatchlistCard: React.FC = ({
+ watchlist,
+ isOwner,
+ onPress,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {watchlist.name}
+
+
+ {isOwner && (
+
+
+ {t("watchlists.you")}
+
+
+ )}
+
+
+
+
+ {watchlist.description && (
+
+ {watchlist.description}
+
+ )}
+
+
+
+
+
+ {watchlist.itemCount ?? 0}{" "}
+ {(watchlist.itemCount ?? 0) === 1
+ ? t("watchlists.item")
+ : t("watchlists.items")}
+
+
+ {watchlist.allowedItemType && (
+
+
+ {watchlist.allowedItemType}
+
+
+ )}
+
+
+ );
+};
+
+const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
+ onCreatePress: _onCreatePress,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {t("watchlists.empty_title")}
+
+
+ {t("watchlists.empty_description")}
+
+
+ );
+};
+
+const NotConfiguredState: React.FC = () => {
+ const { t } = useTranslation();
+ const router = useRouter();
+
+ return (
+
+
+
+ {t("watchlists.not_configured_title")}
+
+
+ {t("watchlists.not_configured_description")}
+
+
+
+ );
+};
+
+export default function WatchlistsScreen() {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const user = useAtomValue(userAtom);
+ const streamystatsEnabled = useStreamystatsEnabled();
+ const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
+ const [refreshing, setRefreshing] = useState(false);
+
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+ await refetch();
+ setRefreshing(false);
+ }, [refetch]);
+
+ const handleCreatePress = useCallback(() => {
+ router.push("/(auth)/(tabs)/(watchlists)/create");
+ }, [router]);
+
+ const handleWatchlistPress = useCallback(
+ (watchlistId: number) => {
+ router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
+ },
+ [router],
+ );
+
+ // Separate watchlists into "mine" and "public"
+ const { myWatchlists, publicWatchlists } = useMemo(() => {
+ if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
+
+ const mine: StreamystatsWatchlist[] = [];
+ const pub: StreamystatsWatchlist[] = [];
+
+ for (const w of watchlists) {
+ if (w.userId === user?.Id) {
+ mine.push(w);
+ } else {
+ pub.push(w);
+ }
+ }
+
+ return { myWatchlists: mine, publicWatchlists: pub };
+ }, [watchlists, user?.Id]);
+
+ // Combine into sections for FlashList
+ const sections = useMemo(() => {
+ const result: Array<
+ | { type: "header"; title: string }
+ | { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
+ > = [];
+
+ if (myWatchlists.length > 0) {
+ result.push({ type: "header", title: t("watchlists.my_watchlists") });
+ for (const w of myWatchlists) {
+ result.push({ type: "watchlist", data: w, isOwner: true });
+ }
+ }
+
+ if (publicWatchlists.length > 0) {
+ result.push({ type: "header", title: t("watchlists.public_watchlists") });
+ for (const w of publicWatchlists) {
+ result.push({ type: "watchlist", data: w, isOwner: false });
+ }
+ }
+
+ return result;
+ }, [myWatchlists, publicWatchlists, t]);
+
+ if (!streamystatsEnabled) {
+ return ;
+ }
+
+ if (!isLoading && (!watchlists || watchlists.length === 0)) {
+ return ;
+ }
+
+ return (
+
+ }
+ renderItem={({ item }) => {
+ if (item.type === "header") {
+ return (
+
+ {item.title}
+
+ );
+ }
+
+ return (
+ handleWatchlistPress(item.data.id)}
+ />
+ );
+ }}
+ getItemType={(item) => item.type}
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index e8a79728..df1ed986 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -7,15 +7,15 @@ import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
-import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
-import { useCallback } from "react";
+import { withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next";
-import { Platform } from "react-native";
+import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
+import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
+import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
-import { storage } from "@/utils/mmkv";
const { Navigator } = createNativeBottomTabNavigator();
@@ -29,25 +29,9 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
- const router = useRouter();
-
- useFocusEffect(
- useCallback(() => {
- const hasShownIntro = storage.getBoolean("hasShownIntro");
- if (!hasShownIntro) {
- const timer = setTimeout(() => {
- router.push("/intro/page");
- }, 1000);
-
- return () => {
- clearTimeout(timer);
- };
- }
- }, []),
- );
return (
- <>
+
({ sfSymbol: "heart.fill" }),
}}
/>
+ require("@/assets/icons/list.png")
+ : (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
+ }}
+ />
- >
+
+
+
);
}
diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx
new file mode 100644
index 00000000..934175fb
--- /dev/null
+++ b/app/(auth)/now-playing.tsx
@@ -0,0 +1,845 @@
+import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
+import { Ionicons } from "@expo/vector-icons";
+import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
+import type {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ ActivityIndicator,
+ Dimensions,
+ Platform,
+ ScrollView,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { Slider } from "react-native-awesome-slider";
+import DraggableFlatList, {
+ type RenderItemParams,
+ ScaleDecorator,
+} from "react-native-draggable-flatlist";
+import { CastButton, CastState } from "react-native-google-cast";
+import { useSharedValue } from "react-native-reanimated";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import TextTicker from "react-native-text-ticker";
+import type { VolumeResult } from "react-native-volume-manager";
+import { Badge } from "@/components/Badge";
+import { Text } from "@/components/common/Text";
+import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
+import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
+import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
+import useRouter from "@/hooks/useAppRouter";
+import { useFavorite } from "@/hooks/useFavorite";
+import { useMusicCast } from "@/hooks/useMusicCast";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import {
+ type RepeatMode,
+ useMusicPlayer,
+} from "@/providers/MusicPlayerProvider";
+import { formatBitrate } from "@/utils/bitrate";
+import { formatDuration } from "@/utils/time";
+
+// Conditionally require VolumeManager (not available on TV)
+const VolumeManager = Platform.isTV
+ ? null
+ : require("react-native-volume-manager");
+
+const formatFileSize = (bytes?: number | null) => {
+ if (!bytes) return null;
+ const sizes = ["B", "KB", "MB", "GB"];
+ if (bytes === 0) return "0 B";
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
+};
+
+const formatSampleRate = (sampleRate?: number | null) => {
+ if (!sampleRate) return null;
+ return `${(sampleRate / 1000).toFixed(1)} kHz`;
+};
+
+const { width: SCREEN_WIDTH } = Dimensions.get("window");
+const ARTWORK_SIZE = SCREEN_WIDTH - 80;
+
+type ViewMode = "player" | "queue";
+
+export default function NowPlayingScreen() {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [viewMode, setViewMode] = useState("player");
+ const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
+ const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
+ const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
+
+ const {
+ isConnected: isCastConnected,
+ castQueue,
+ castState,
+ } = useMusicCast({
+ api,
+ userId: user?.Id,
+ });
+
+ const {
+ currentTrack,
+ queue,
+ queueIndex,
+ isPlaying,
+ isLoading,
+ progress,
+ duration,
+ repeatMode,
+ shuffleEnabled,
+ mediaSource,
+ isTranscoding,
+ togglePlayPause,
+ next,
+ previous,
+ seek,
+ setRepeatMode,
+ toggleShuffle,
+ jumpToIndex,
+ removeFromQueue,
+ reorderQueue,
+ stop,
+ pause,
+ } = useMusicPlayer();
+
+ const { isFavorite, toggleFavorite } = useFavorite(
+ currentTrack ?? ({ Id: "" } as BaseItemDto),
+ );
+
+ const sliderProgress = useSharedValue(0);
+ const sliderMin = useSharedValue(0);
+ const sliderMax = useSharedValue(1);
+
+ useEffect(() => {
+ sliderProgress.value = progress;
+ }, [progress, sliderProgress]);
+
+ useEffect(() => {
+ sliderMax.value = duration > 0 ? duration : 1;
+ }, [duration, sliderMax]);
+
+ // Auto-cast queue when Chromecast becomes connected and pause local playback
+ const prevCastState = useRef(null);
+ useEffect(() => {
+ if (
+ castState === CastState.CONNECTED &&
+ prevCastState.current !== CastState.CONNECTED &&
+ queue.length > 0
+ ) {
+ // Just connected - pause local playback and cast the queue
+ pause();
+ castQueue({ queue, startIndex: queueIndex });
+ }
+ prevCastState.current = castState;
+ }, [castState, queue, queueIndex, castQueue, pause]);
+
+ const imageUrl = useMemo(() => {
+ if (!api || !currentTrack) return null;
+ const albumId = currentTrack.AlbumId || currentTrack.ParentId;
+ if (albumId) {
+ return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
+ }
+ return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
+ }, [api, currentTrack]);
+
+ const progressText = useMemo(() => {
+ const progressTicks = progress * 10000000;
+ return formatDuration(progressTicks);
+ }, [progress]);
+
+ const _durationText = useMemo(() => {
+ const durationTicks = duration * 10000000;
+ return formatDuration(durationTicks);
+ }, [duration]);
+
+ const remainingText = useMemo(() => {
+ const remaining = Math.max(0, duration - progress);
+ const remainingTicks = remaining * 10000000;
+ return `-${formatDuration(remainingTicks)}`;
+ }, [duration, progress]);
+
+ const handleSliderComplete = useCallback(
+ (value: number) => {
+ seek(value);
+ },
+ [seek],
+ );
+
+ const handleClose = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const _handleStop = useCallback(() => {
+ stop();
+ router.back();
+ }, [stop, router]);
+
+ const cycleRepeatMode = useCallback(() => {
+ const modes: RepeatMode[] = ["off", "all", "one"];
+ const currentIndex = modes.indexOf(repeatMode);
+ const nextMode = modes[(currentIndex + 1) % modes.length];
+ setRepeatMode(nextMode);
+ }, [repeatMode, setRepeatMode]);
+
+ const handleOptionsPress = useCallback(() => {
+ setTrackOptionsOpen(true);
+ }, []);
+
+ const handleAddToPlaylist = useCallback(() => {
+ setPlaylistPickerOpen(true);
+ }, []);
+
+ const handleCreateNewPlaylist = useCallback(() => {
+ setCreatePlaylistOpen(true);
+ }, []);
+
+ const getRepeatIcon = (): string => {
+ switch (repeatMode) {
+ case "one":
+ return "repeat";
+ case "all":
+ return "repeat";
+ default:
+ return "repeat";
+ }
+ };
+
+ const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
+ const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
+
+ if (!currentTrack) {
+ return (
+
+
+ No track playing
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+ setViewMode("player")}
+ className='px-3 py-1'
+ >
+
+ Now Playing
+
+
+ setViewMode("queue")}
+ className='px-3 py-1'
+ >
+
+ Queue ({queue.length})
+
+
+
+ {/* Empty placeholder to balance header layout */}
+
+
+
+ {viewMode === "player" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+interface PlayerViewProps {
+ api: any;
+ currentTrack: BaseItemDto;
+ imageUrl: string | null;
+ sliderProgress: any;
+ sliderMin: any;
+ sliderMax: any;
+ progressText: string;
+ remainingText: string;
+ isPlaying: boolean;
+ isLoading: boolean;
+ repeatMode: RepeatMode;
+ shuffleEnabled: boolean;
+ canGoNext: boolean;
+ canGoPrevious: boolean;
+ onSliderComplete: (value: number) => void;
+ onTogglePlayPause: () => void;
+ onNext: () => void;
+ onPrevious: () => void;
+ onCycleRepeat: () => void;
+ onToggleShuffle: () => void;
+ getRepeatIcon: () => string;
+ mediaSource: MediaSourceInfo | null;
+ isTranscoding: boolean;
+ isFavorite: boolean | undefined;
+ onToggleFavorite: () => void;
+ onOptionsPress: () => void;
+ isCastConnected: boolean;
+}
+
+const PlayerView: React.FC = ({
+ currentTrack,
+ imageUrl,
+ sliderProgress,
+ sliderMin,
+ sliderMax,
+ progressText,
+ remainingText,
+ isPlaying,
+ isLoading,
+ repeatMode,
+ shuffleEnabled,
+ canGoNext,
+ canGoPrevious,
+ onSliderComplete,
+ onTogglePlayPause,
+ onNext,
+ onPrevious,
+ onCycleRepeat,
+ onToggleShuffle,
+ getRepeatIcon,
+ mediaSource,
+ isTranscoding,
+ isFavorite,
+ onToggleFavorite,
+ onOptionsPress,
+ isCastConnected,
+}) => {
+ const audioStream = useMemo(() => {
+ return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
+ }, [mediaSource]);
+
+ // Volume slider state
+ const volumeProgress = useSharedValue(0);
+ const volumeMin = useSharedValue(0);
+ const volumeMax = useSharedValue(1);
+ const isTv = Platform.isTV;
+
+ useEffect(() => {
+ if (isTv || !VolumeManager) return;
+ // Get initial volume
+ VolumeManager.getVolume().then(({ volume }: { volume: number }) => {
+ volumeProgress.value = volume;
+ });
+ // Listen to volume changes
+ const listener = VolumeManager.addVolumeListener((result: VolumeResult) => {
+ volumeProgress.value = result.volume;
+ });
+ return () => listener.remove();
+ }, [isTv, volumeProgress]);
+
+ const handleVolumeChange = useCallback((value: number) => {
+ if (VolumeManager) {
+ VolumeManager.setVolume(value);
+ }
+ }, []);
+
+ const fileSize = formatFileSize(mediaSource?.Size);
+ const codec = audioStream?.Codec?.toUpperCase();
+ const bitrate = formatBitrate(audioStream?.BitRate);
+ const sampleRate = formatSampleRate(audioStream?.SampleRate);
+ const playbackMethod = isTranscoding ? "Transcoding" : "Direct";
+
+ const hasAudioStats =
+ mediaSource && (fileSize || codec || bitrate || sampleRate);
+ return (
+
+ {/* Album artwork */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Track info with actions */}
+
+
+
+ t}
+ >
+ {currentTrack.Name}
+
+ t}
+ >
+ {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
+
+
+
+
+
+
+
+
+
+
+ {/* Audio Stats */}
+ {hasAudioStats && (
+
+ {fileSize && }
+ {codec && }
+
+ }
+ />
+ {bitrate && bitrate !== "N/A" && (
+
+ )}
+ {sampleRate && }
+
+ )}
+
+
+ {/* Progress slider */}
+
+ null}
+ sliderHeight={8}
+ containerStyle={{ borderRadius: 100 }}
+ renderBubble={() => null}
+ />
+
+ {progressText}
+ {remainingText}
+
+
+
+ {/* Main Controls with Shuffle & Repeat */}
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {repeatMode === "one" && (
+
+ 1
+
+ )}
+
+
+
+ {/* Volume Slider */}
+ {!isTv && VolumeManager && (
+
+
+
+ null}
+ sliderHeight={8}
+ containerStyle={{ borderRadius: 100 }}
+ renderBubble={() => null}
+ />
+
+
+
+ )}
+
+ {/* AirPlay & Chromecast Buttons */}
+ {!isTv && (
+
+ {/* AirPlay (iOS only) */}
+ {Platform.OS === "ios" && (
+
+
+
+ )}
+ {/* Chromecast */}
+
+
+ )}
+
+ );
+};
+
+interface QueueViewProps {
+ api: any;
+ queue: BaseItemDto[];
+ queueIndex: number;
+ onJumpToIndex: (index: number) => void;
+ onRemoveFromQueue: (index: number) => void;
+ onReorderQueue: (newQueue: BaseItemDto[]) => void;
+}
+
+const QueueView: React.FC = ({
+ api,
+ queue,
+ queueIndex,
+ onJumpToIndex,
+ onRemoveFromQueue,
+ onReorderQueue,
+}) => {
+ const renderQueueItem = useCallback(
+ ({ item, drag, isActive, getIndex }: RenderItemParams) => {
+ const index = getIndex() ?? 0;
+ const isCurrentTrack = index === queueIndex;
+ const isPast = index < queueIndex;
+
+ const albumId = item.AlbumId || item.ParentId;
+ const imageUrl = api
+ ? albumId
+ ? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
+ : `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
+ : null;
+
+ return (
+
+ onJumpToIndex(index)}
+ onLongPress={drag}
+ disabled={isActive}
+ className='flex-row items-center px-4 py-3'
+ style={{
+ opacity: isPast && !isActive ? 0.5 : 1,
+ backgroundColor: isActive
+ ? "#2a2a2a"
+ : isCurrentTrack
+ ? "rgba(147, 52, 233, 0.3)"
+ : "#121212",
+ }}
+ >
+ {/* Drag handle */}
+
+
+
+
+ {/* Album art */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Track info */}
+
+
+ {item.Name}
+
+
+ {item.Artists?.join(", ") || item.AlbumArtist}
+
+
+
+ {/* Now playing indicator */}
+ {isCurrentTrack && (
+
+ )}
+
+ {/* Remove button (not for current track) */}
+ {!isCurrentTrack && (
+ onRemoveFromQueue(index)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ className='p-2'
+ >
+
+
+ )}
+
+
+ );
+ },
+ [api, queueIndex, onJumpToIndex, onRemoveFromQueue],
+ );
+
+ const handleDragEnd = useCallback(
+ ({ data }: { data: BaseItemDto[] }) => {
+ onReorderQueue(data);
+ },
+ [onReorderQueue],
+ );
+
+ const history = queue.slice(0, queueIndex);
+
+ return (
+ `${item.Id}-${index}`}
+ renderItem={renderQueueItem}
+ onDragEnd={handleDragEnd}
+ showsVerticalScrollIndicator={false}
+ ListHeaderComponent={
+
+
+ {history.length > 0 ? "Playing from queue" : "Up next"}
+
+
+ }
+ ListEmptyComponent={
+
+ Queue is empty
+
+ }
+ />
+ );
+};
diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx
index 97a3f7eb..5604160e 100644
--- a/app/(auth)/player/_layout.tsx
+++ b/app/(auth)/player/_layout.tsx
@@ -1,7 +1,33 @@
import { Stack } from "expo-router";
+import { useEffect } from "react";
+import { AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useSettings } from "@/utils/atoms/settings";
+
export default function Layout() {
+ const { settings } = useSettings();
+ const { lockOrientation, unlockOrientation } = useOrientation();
+
+ useEffect(() => {
+ if (settings?.defaultVideoOrientation) {
+ lockOrientation(settings.defaultVideoOrientation);
+ }
+
+ // Re-apply orientation lock when app returns to foreground (iOS resets it)
+ const subscription = AppState.addEventListener("change", (nextAppState) => {
+ if (nextAppState === "active" && settings?.defaultVideoOrientation) {
+ lockOrientation(settings.defaultVideoOrientation);
+ }
+ });
+
+ return () => {
+ subscription.remove();
+ unlockOrientation();
+ };
+ }, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
+
return (
<>
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index 0d93f57f..2ef8cf39 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -3,7 +3,6 @@ import {
type MediaSourceInfo,
PlaybackOrder,
PlaybackProgressInfo,
- PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
@@ -11,64 +10,75 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
-import { router, useGlobalSearchParams, useNavigation } from "expo-router";
+import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { Alert, Platform, View } from "react-native";
+import { Alert, Platform, useWindowDimensions, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
-
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
+import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
+import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import {
- OUTLINE_THICKNESS,
- OutlineThickness,
- VLC_COLORS,
- VLCColor,
-} from "@/constants/SubtitleConstants";
+ PlaybackSpeedScope,
+ updatePlaybackSpeedSettings,
+} from "@/components/video-player/controls/utils/playback-speed-settings";
+import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
+import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
-import { VlcPlayerView } from "@/modules";
-import type {
- PlaybackStatePayload,
- ProgressUpdatePayload,
- VlcPlayerViewRef,
-} from "@/modules/VlcPlayer.types";
+import {
+ type MpvOnErrorEventPayload,
+ type MpvOnPlaybackStateChangePayload,
+ type MpvOnProgressEventPayload,
+ MpvPlayerView,
+ type MpvPlayerViewRef,
+ type MpvVideoSource,
+} from "@/modules";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
+
import { useSettings } from "@/utils/atoms/settings";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import {
+ getMpvAudioId,
+ getMpvSubtitleId,
+} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function page() {
- const videoRef = useRef(null);
+ const videoRef = useRef(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const navigation = useNavigation();
+ const router = useRouter();
+ const { settings, updateSettings } = useSettings();
+
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false);
- const [aspectRatio, setAspectRatio] = useState<
- "default" | "16:9" | "4:3" | "1:1" | "21:9"
- >("default");
- const [scaleFactor, setScaleFactor] = useState<
- 1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
- >(1.0);
+ const [isZoomedToFill, setIsZoomedToFill] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
+ const [tracksReady, setTracksReady] = useState(false);
+ const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
+ const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -78,10 +88,9 @@ export default function page() {
: require("react-native-volume-manager");
const downloadUtils = useDownload();
- const downloadedFiles = useMemo(
- () => downloadUtils.getDownloadedItems(),
- [downloadUtils.getDownloadedItems],
- );
+ // Call directly instead of useMemo - the function reference doesn't change
+ // when data updates, only when the provider initializes
+ const downloadedFiles = downloadUtils.getDownloadedItems();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -100,7 +109,7 @@ export default function page() {
bitrateValue: bitrateValueStr,
offline: offlineStr,
playbackPosition: playbackPositionFromUrl,
- } = useGlobalSearchParams<{
+ } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
@@ -110,13 +119,14 @@ export default function page() {
/** Playback position in ticks. */
playbackPosition?: string;
}>();
- const { settings } = useSettings();
const { lockOrientation, unlockOrientation } = useOrientation();
const offline = offlineStr === "true";
- const playbackManager = usePlaybackManager();
+ const playbackManager = usePlaybackManager({ isOffline: offline });
- const audioIndex = audioIndexStr
+ // Audio index: use URL param if provided, otherwise use stored index for offline playback
+ // This is computed after downloadedItem is available, see audioIndexResolved below
+ const audioIndexFromUrl = audioIndexStr
? Number.parseInt(audioIndexStr, 10)
: undefined;
const subtitleIndex = subtitleIndexStr
@@ -135,13 +145,49 @@ export default function page() {
isError: false,
});
+ // Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
+ const audioIndex = useMemo(() => {
+ if (audioIndexFromUrl !== undefined) {
+ return audioIndexFromUrl;
+ }
+ if (offline && downloadedItem?.userData?.audioStreamIndex !== undefined) {
+ return downloadedItem.userData.audioStreamIndex;
+ }
+ return undefined;
+ }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
+
+ // Get the playback speed for this item based on settings
+ const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
+ item,
+ settings,
+ );
+
+ // Handler for changing playback speed
+ const handleSetPlaybackSpeed = useCallback(
+ async (speed: number, scope: PlaybackSpeedScope) => {
+ // Update settings based on scope
+ updatePlaybackSpeedSettings(
+ speed,
+ scope,
+ item ?? undefined,
+ settings,
+ updateSettings,
+ );
+
+ // Apply speed to the current player (MPV)
+ setCurrentPlaybackSpeed(speed);
+ await videoRef.current?.setSpeed?.(speed);
+ },
+ [item, settings, updateSettings],
+ );
+
/** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10);
}
return item?.UserData?.PlaybackPositionTicks ?? 0;
- }, [playbackPositionFromUrl]);
+ }, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
useEffect(() => {
const fetchItemData = async () => {
@@ -174,6 +220,7 @@ export default function page() {
}
}, [itemId, offline, api, user?.Id]);
+ // Lock orientation based on user settings
useEffect(() => {
if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
@@ -182,7 +229,7 @@ export default function page() {
return () => {
unlockOrientation();
};
- }, [settings?.defaultVideoOrientation]);
+ }, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
interface Stream {
mediaSource: MediaSourceInfo;
@@ -230,21 +277,25 @@ export default function page() {
return;
}
- const native = generateDeviceProfile();
- const transcoding = generateDeviceProfile({ transcode: true });
+ // Calculate start ticks directly from item to avoid stale closure
+ const startTicks = playbackPositionFromUrl
+ ? Number.parseInt(playbackPositionFromUrl, 10)
+ : (item?.UserData?.PlaybackPositionTicks ?? 0);
+
const res = await getStreamUrl({
api,
item,
- startTimeTicks: getInitialPlaybackTicks(),
+ startTimeTicks: startTicks,
userId: user.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
- deviceProfile: bitrateValue ? transcoding : native,
+ deviceProfile: generateDeviceProfile(),
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
+
if (!sessionId || !mediaSource || !url) {
Alert.alert(
t("player.error"),
@@ -273,36 +324,43 @@ export default function page() {
]);
useEffect(() => {
- if (!stream || !api) return;
+ if (!stream || !api || offline) return;
const reportPlaybackStart = async () => {
- await getPlaystateApi(api).reportPlaybackStart({
- playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
- });
+ const progressInfo = currentPlayStateInfo();
+ if (progressInfo) {
+ await getPlaystateApi(api).reportPlaybackStart({
+ playbackStartInfo: progressInfo,
+ });
+ }
};
reportPlaybackStart();
- }, [stream, api]);
+ }, [stream, api, offline]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
- playbackManager.reportPlaybackProgress(
- currentPlayStateInfo() as PlaybackProgressInfo,
- );
+ const progressInfo = currentPlayStateInfo();
+ if (progressInfo) {
+ playbackManager.reportPlaybackProgress(progressInfo);
+ }
} else {
videoRef.current?.play();
- await getPlaystateApi(api!).reportPlaybackStart({
- playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
- });
+ const progressInfo = currentPlayStateInfo();
+ if (!offline && api) {
+ await getPlaystateApi(api).reportPlaybackStart({
+ playbackStartInfo: progressInfo,
+ });
+ }
}
};
const reportPlaybackStopped = useCallback(async () => {
- if (!item?.Id || !stream?.sessionId) return;
+ if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get());
- await getPlaystateApi(api!).onPlaybackStopped({
+ await getPlaystateApi(api).onPlaybackStopped({
itemId: item.Id,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
@@ -325,7 +383,7 @@ export default function page() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
- videoRef.current?.stop();
+ videoRef.current?.pause();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
@@ -336,21 +394,24 @@ export default function page() {
};
}, [navigation, stop]);
- const currentPlayStateInfo = useCallback(() => {
+ const currentPlayStateInfo = useCallback(():
+ | PlaybackProgressInfo
+ | undefined => {
if (!stream || !item?.Id) return;
+
return {
- itemId: item.Id,
- audioStreamIndex: audioIndex ? audioIndex : undefined,
- subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
- mediaSourceId: mediaSourceId,
- positionTicks: msToTicks(progress.get()),
- isPaused: !isPlaying,
- playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
- playSessionId: stream.sessionId,
- isMuted: isMuted,
- canSeek: true,
- repeatMode: RepeatMode.RepeatNone,
- playbackOrder: PlaybackOrder.Default,
+ ItemId: item.Id,
+ AudioStreamIndex: audioIndex ? audioIndex : undefined,
+ SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ MediaSourceId: mediaSourceId,
+ PositionTicks: msToTicks(progress.get()),
+ IsPaused: !isPlaying,
+ PlayMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
+ PlaySessionId: stream.sessionId,
+ IsMuted: isMuted,
+ CanSeek: true,
+ RepeatMode: RepeatMode.RepeatNone,
+ PlaybackOrder: PlaybackOrder.Default,
};
}, [
stream,
@@ -379,11 +440,15 @@ export default function page() {
[],
);
+ /** Progress handler for MPV - position in seconds */
const onProgress = useCallback(
- async (data: ProgressUpdatePayload) => {
+ async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return;
- const { currentTime } = data.nativeEvent;
+ const { position } = data.nativeEvent;
+ // MPV reports position in seconds, convert to ms
+ const currentTime = position * 1000;
+
if (isBuffering) {
setIsBuffering(false);
}
@@ -425,10 +490,85 @@ export default function page() {
);
/** Gets the initial playback position in seconds. */
- const startPosition = useMemo(() => {
+ const _startPosition = useMemo(() => {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
+ /** Build video source config for MPV */
+ const videoSource = useMemo(() => {
+ if (!stream?.url) return undefined;
+
+ const mediaSource = stream.mediaSource;
+ const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
+
+ // Get external subtitle URLs
+ // - Online: prepend API base path to server URLs
+ // - Offline: use local file paths (stored in DeliveryUrl during download)
+ let externalSubs: string[] | undefined;
+ if (!offline && api?.basePath) {
+ externalSubs = mediaSource?.MediaStreams?.filter(
+ (s) =>
+ s.Type === "Subtitle" &&
+ s.DeliveryMethod === "External" &&
+ s.DeliveryUrl,
+ ).map((s) => `${api.basePath}${s.DeliveryUrl}`);
+ } else if (offline) {
+ externalSubs = mediaSource?.MediaStreams?.filter(
+ (s) =>
+ s.Type === "Subtitle" &&
+ s.DeliveryMethod === "External" &&
+ s.DeliveryUrl,
+ ).map((s) => s.DeliveryUrl!);
+ }
+
+ // Calculate track IDs for initial selection
+ const initialSubtitleId = getMpvSubtitleId(
+ mediaSource,
+ subtitleIndex,
+ isTranscoding,
+ );
+ const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
+
+ // Calculate start position directly here to avoid timing issues
+ const startTicks = playbackPositionFromUrl
+ ? Number.parseInt(playbackPositionFromUrl, 10)
+ : (item?.UserData?.PlaybackPositionTicks ?? 0);
+ const startPos = ticksToSeconds(startTicks);
+
+ // Build source config - headers only needed for online streaming
+ const source: MpvVideoSource = {
+ url: stream.url,
+ startPosition: startPos,
+ autoplay: true,
+ initialSubtitleId,
+ initialAudioId,
+ };
+
+ // Add external subtitles only for online playback
+ if (externalSubs && externalSubs.length > 0) {
+ source.externalSubtitles = externalSubs;
+ }
+
+ // Add auth headers only for online streaming (not for local file:// URLs)
+ if (!offline && api?.accessToken) {
+ source.headers = {
+ Authorization: `MediaBrowser Token="${api.accessToken}"`,
+ };
+ }
+
+ return source;
+ }, [
+ stream?.url,
+ stream?.mediaSource,
+ item?.UserData?.PlaybackPositionTicks,
+ playbackPositionFromUrl,
+ api?.basePath,
+ api?.accessToken,
+ subtitleIndex,
+ audioIndex,
+ offline,
+ ]);
+
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
@@ -508,11 +648,15 @@ export default function page() {
setVolume: setVolumeCb,
});
+ /** Playback state handler for MPV */
const onPlaybackStateChanged = useCallback(
- async (e: PlaybackStatePayload) => {
- const { state, isBuffering, isPlaying } = e.nativeEvent;
- if (state === "Playing") {
+ async (e: { nativeEvent: MpvOnPlaybackStateChangePayload }) => {
+ const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
+
+ if (playing) {
setIsPlaying(true);
+ setIsBuffering(false);
+ setHasPlaybackStarted(true);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
@@ -522,7 +666,7 @@ export default function page() {
return;
}
- if (state === "Paused") {
+ if (isPaused) {
setIsPlaying(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
@@ -533,86 +677,25 @@ export default function page() {
return;
}
- if (isPlaying) {
- setIsPlaying(true);
- setIsBuffering(false);
- } else if (isBuffering) {
- setIsBuffering(true);
+ if (isLoading !== undefined) {
+ setIsBuffering(isLoading);
}
},
[playbackManager, item?.Id, progress],
);
- const allAudio =
- stream?.mediaSource.MediaStreams?.filter(
- (audio) => audio.Type === "Audio",
- ) || [];
-
- // Move all the external subtitles last, because vlc places them last.
- const allSubs =
- stream?.mediaSource.MediaStreams?.filter(
- (sub) => sub.Type === "Subtitle",
- ).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
-
- const externalSubtitles = allSubs
- .filter((sub: any) => sub.DeliveryMethod === "External")
- .map((sub: any) => ({
- name: sub.DisplayTitle,
- DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
- }));
- /** The text based subtitle tracks */
- const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
- /** The user chosen subtitle track from the server */
- const chosenSubtitleTrack = allSubs.find(
- (sub) => sub.Index === subtitleIndex,
+ /** PiP handler for MPV */
+ const _onPictureInPictureChange = useCallback(
+ (e: { nativeEvent: { isActive: boolean } }) => {
+ const { isActive } = e.nativeEvent;
+ setIsPipMode(isActive);
+ // Hide controls when entering PiP
+ if (isActive) {
+ _setShowControls(false);
+ }
+ },
+ [],
);
- /** The user chosen audio track from the server */
- const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
- /** Whether the stream we're playing is not transcoding*/
- const notTranscoding = !stream?.mediaSource.TranscodingUrl;
- /** The initial options to pass to the VLC Player */
- const initOptions = [``];
- if (
- chosenSubtitleTrack &&
- (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
- ) {
- // If not transcoding, we can the index as normal.
- // If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
- const finalIndex = notTranscoding
- ? allSubs.indexOf(chosenSubtitleTrack)
- : [...textSubs].reverse().indexOf(chosenSubtitleTrack);
- initOptions.push(`--sub-track=${finalIndex}`);
-
- // Add VLC subtitle styling options from settings
- const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
- const backgroundColor = (settings.vlcBackgroundColor ??
- "Black") as VLCColor;
- const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
- const outlineThickness = (settings.vlcOutlineThickness ??
- "Normal") as OutlineThickness;
- const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
- const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
- const isBold = settings.vlcIsBold ?? false;
- // Add subtitle styling options
- initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
- initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
- initOptions.push(
- `--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
- );
- initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
- initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
- initOptions.push(
- `--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
- );
- initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
- initOptions.push("--sub-margin=40");
- if (isBold) {
- initOptions.push("--freetype-bold");
- }
- }
- if (notTranscoding && chosenAudioTrack) {
- initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
- }
const [isMounted, setIsMounted] = useState(false);
@@ -626,6 +709,7 @@ export default function page() {
const startPictureInPicture = useCallback(async () => {
return videoRef.current?.startPictureInPicture?.();
}, []);
+
const play = useCallback(() => {
videoRef.current?.play?.();
}, []);
@@ -635,69 +719,92 @@ export default function page() {
}, []);
const seek = useCallback((position: number) => {
- videoRef.current?.seekTo?.(position);
- }, []);
- const getAudioTracks = useCallback(async () => {
- return videoRef.current?.getAudioTracks?.() || null;
+ // MPV expects seconds, convert from ms
+ videoRef.current?.seekTo?.(position / 1000);
}, []);
- const getSubtitleTracks = useCallback(async () => {
- return videoRef.current?.getSubtitleTracks?.() || null;
- }, []);
+ const handleZoomToggle = useCallback(async () => {
+ const newZoomState = !isZoomedToFill;
+ await videoRef.current?.setZoomedToFill?.(newZoomState);
+ setIsZoomedToFill(newZoomState);
- const setSubtitleTrack = useCallback((index: number) => {
- videoRef.current?.setSubtitleTrack?.(index);
- }, []);
-
- const setSubtitleURL = useCallback((url: string, _customName?: string) => {
- // Note: VlcPlayer type only expects url parameter
- videoRef.current?.setSubtitleURL?.(url);
- }, []);
-
- const setAudioTrack = useCallback((index: number) => {
- videoRef.current?.setAudioTrack?.(index);
- }, []);
-
- const setVideoAspectRatio = useCallback(
- async (aspectRatio: string | null) => {
- return (
- videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
- Promise.resolve()
+ // Adjust subtitle position to compensate for video cropping when zoomed
+ if (newZoomState) {
+ // Get video dimensions from mediaSource
+ const videoStream = stream?.mediaSource?.MediaStreams?.find(
+ (s) => s.Type === "Video",
);
- },
- [],
- );
+ const videoWidth = videoStream?.Width ?? 1920;
+ const videoHeight = videoStream?.Height ?? 1080;
- const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
- return (
- videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
- );
- }, []);
+ const videoAR = videoWidth / videoHeight;
+ const screenAR = screenWidth / screenHeight;
- // Prepare metadata for iOS native media controls
- const nowPlayingMetadata = useMemo(() => {
- if (!item || !api) return undefined;
+ if (screenAR > videoAR) {
+ // Screen is wider than video - video height extends beyond screen
+ // Calculate how much of the video is cropped at the bottom (as % of video height)
+ const bottomCropPercent = 50 * (1 - videoAR / screenAR);
+ // Only adjust by 70% of the crop to keep a comfortable margin from the edge
+ // (subtitles already have some built-in padding from the bottom)
+ const adjustmentFactor = 0.7;
+ const newSubPos = Math.round(
+ 100 - bottomCropPercent * adjustmentFactor,
+ );
+ await videoRef.current?.setSubtitlePosition?.(newSubPos);
+ }
+ // If videoAR >= screenAR, sides are cropped but bottom is visible, no adjustment needed
+ } else {
+ // Restore to default position (bottom of video frame)
+ await videoRef.current?.setSubtitlePosition?.(100);
+ }
+ }, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
- const artworkUri = getPrimaryImageUrl({
- api,
- item,
- quality: 90,
- width: 500,
- });
+ // Apply subtitle settings when video loads
+ useEffect(() => {
+ if (!isVideoLoaded || !videoRef.current) return;
- return {
- title: item.Name || "",
- artist:
- item.Type === "Episode"
- ? item.SeriesName || ""
- : item.AlbumArtist || "",
- albumTitle:
- item.Type === "Episode" && item.SeasonName
- ? item.SeasonName
- : undefined,
- artworkUri: artworkUri || undefined,
+ const applySubtitleSettings = async () => {
+ if (settings.mpvSubtitleScale !== undefined) {
+ await videoRef.current?.setSubtitleScale?.(settings.mpvSubtitleScale);
+ }
+ if (settings.mpvSubtitleMarginY !== undefined) {
+ await videoRef.current?.setSubtitleMarginY?.(
+ settings.mpvSubtitleMarginY,
+ );
+ }
+ if (settings.mpvSubtitleAlignX !== undefined) {
+ await videoRef.current?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
+ }
+ if (settings.mpvSubtitleAlignY !== undefined) {
+ await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
+ }
+ if (settings.mpvSubtitleFontSize !== undefined) {
+ await videoRef.current?.setSubtitleFontSize?.(
+ settings.mpvSubtitleFontSize,
+ );
+ }
+ // Apply subtitle size from general settings
+ if (settings.subtitleSize) {
+ await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
+ }
};
- }, [item, api]);
+
+ applySubtitleSettings();
+ }, [isVideoLoaded, settings]);
+
+ // Apply initial playback speed when video loads
+ useEffect(() => {
+ if (!isVideoLoaded || !videoRef.current) return;
+
+ const applyInitialPlaybackSpeed = async () => {
+ if (initialPlaybackSpeed !== 1.0) {
+ setCurrentPlaybackSpeed(initialPlaybackSpeed);
+ await videoRef.current?.setSpeed?.(initialPlaybackSpeed);
+ }
+ };
+
+ applyInitialPlaybackSpeed();
+ }, [isVideoLoaded, initialPlaybackSpeed]);
// Show error UI first, before checking loading/missingโdata
if (itemStatus.isError || streamStatus.isError) {
@@ -708,7 +815,7 @@ export default function page() {
);
}
- // Then show loader while either side is still fetching or data isnโt present
+ // Then show loader while either side is still fetching or data isn't present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// โฆloader UIโฆ
return (
@@ -726,91 +833,98 @@ export default function page() {
);
return (
-
-
+
- {
- setIsVideoLoaded(true);
- }}
- onVideoError={(e) => {
- console.error("Video Error:", e.nativeEvent);
- Alert.alert(
- t("player.error"),
- t("player.an_error_occured_while_playing_the_video"),
- );
- writeToLog("ERROR", "Video Error", e.nativeEvent);
- }}
- onPipStarted={(e) => {
- setIsPipMode(e.nativeEvent.pipStarted);
- }}
- />
-
- {isMounted === true && item && !isPipMode && (
-
- )}
-
+
+
+
+ setIsVideoLoaded(true)}
+ onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
+ console.error("Video Error:", e.nativeEvent);
+ Alert.alert(
+ t("player.error"),
+ t("player.an_error_occured_while_playing_the_video"),
+ );
+ writeToLog("ERROR", "Video Error", e.nativeEvent);
+ }}
+ onTracksReady={() => {
+ setTracksReady(true);
+ }}
+ />
+ {!hasPlaybackStarted && (
+
+
+
+ )}
+
+ {isMounted === true && item && !isPipMode && (
+
+ )}
+
+
+
+
);
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index b373739c..79278b70 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,8 +1,11 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
+import NetInfo from "@react-native-community/netinfo";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
+import { onlineManager, QueryClient } from "@tanstack/react-query";
+import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Platform } from "react-native";
@@ -10,13 +13,16 @@ import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
+import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import {
apiAtom,
getOrSetDeviceId,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
+import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
+import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
@@ -42,7 +48,7 @@ import type {
NotificationResponse,
} from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
-import { router, Stack, useSegments } from "expo-router";
+import { Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
@@ -51,6 +57,7 @@ import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
+import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import "react-native-reanimated";
@@ -75,14 +82,9 @@ SplashScreen.setOptions({
fade: true,
});
-function redirect(notification: typeof Notifications.Notification) {
- const url = notification.request.content.data?.url;
- if (url) {
- router.push(url);
- }
-}
-
function useNotificationObserver() {
+ const router = useRouter();
+
useEffect(() => {
if (Platform.isTV) return;
@@ -93,14 +95,17 @@ function useNotificationObserver() {
if (!isMounted || !response?.notification) {
return;
}
- redirect(response?.notification);
+ const url = response?.notification.request.content.data?.url;
+ if (url) {
+ router.push(url);
+ }
},
);
return () => {
isMounted = false;
};
- }, []);
+ }, [router]);
}
if (!Platform.isTV) {
@@ -184,11 +189,39 @@ export default function RootLayout() {
);
}
+// Set up online manager for network-aware query behavior
+onlineManager.setEventListener((setOnline) => {
+ return NetInfo.addEventListener((state) => {
+ setOnline(!!state.isConnected);
+ });
+});
+
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- staleTime: 30000,
+ staleTime: 0, // Always stale - triggers background refetch on mount
+ gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for offline
+ networkMode: "offlineFirst", // Return cache first, refetch if online
+ refetchOnMount: true, // Refetch when component mounts
+ refetchOnReconnect: true, // Refetch when network reconnects
+ refetchOnWindowFocus: false, // Not needed for mobile
+ retry: (failureCount) => {
+ if (!onlineManager.isOnline()) return false;
+ return failureCount < 3;
+ },
},
+ mutations: {
+ networkMode: "online", // Only run mutations when online
+ },
+ },
+});
+
+// Create MMKV-based persister for offline support
+const mmkvPersister = createSyncStoragePersister({
+ storage: {
+ getItem: (key) => storage.getString(key) ?? null,
+ setItem: (key, value) => storage.set(key, value),
+ removeItem: (key) => storage.remove(key),
},
});
@@ -197,6 +230,7 @@ function Layout() {
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const _segments = useSegments();
+ const router = useRouter();
useEffect(() => {
i18n.changeLanguage(
@@ -289,9 +323,6 @@ function Layout() {
responseListener.current =
Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => {
- // redirect if internal notification
- redirect(response?.notification);
-
// Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content;
writeInfoLog(`Notification ${title} opened`, data);
@@ -337,68 +368,94 @@ function Layout() {
}, [user]);
return (
-
+ {
+ // Only persist successful queries
+ return query.state.status === "success";
+ },
+ },
+ }}
+ >
-
-
-
-
-
-
-
-
-
-
- null,
- }}
- />
- null,
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ null,
+ }}
+ />
+ null,
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
);
}
diff --git a/app/login.tsx b/app/login.tsx
index 46e6ab14..33d06d41 100644
--- a/app/login.tsx
+++ b/app/login.tsx
@@ -10,6 +10,7 @@ import {
Keyboard,
KeyboardAvoidingView,
Platform,
+ Switch,
TouchableOpacity,
View,
} from "react-native";
@@ -20,8 +21,13 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
+import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
+import type {
+ AccountSecurityType,
+ SavedServer,
+} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
@@ -31,8 +37,14 @@ const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
- const { setServer, login, removeServer, initiateQuickConnect } =
- useJellyfin();
+ const {
+ setServer,
+ login,
+ removeServer,
+ initiateQuickConnect,
+ loginWithSavedCredential,
+ loginWithPassword,
+ } = useJellyfin();
const {
apiUrl: _apiUrl,
@@ -52,6 +64,14 @@ const Login: React.FC = () => {
password: _password || "",
});
+ // Save account state
+ const [saveAccount, setSaveAccount] = useState(false);
+ const [showSaveModal, setShowSaveModal] = useState(false);
+ const [pendingLogin, setPendingLogin] = useState<{
+ username: string;
+ password: string;
+ } | null>(null);
+
/**
* A way to auto login based on a link
*/
@@ -96,12 +116,34 @@ const Login: React.FC = () => {
const handleLogin = async () => {
Keyboard.dismiss();
+ const result = CredentialsSchema.safeParse(credentials);
+ if (!result.success) return;
+
+ if (saveAccount) {
+ // Show save account modal to choose security type
+ setPendingLogin({
+ username: credentials.username,
+ password: credentials.password,
+ });
+ setShowSaveModal(true);
+ } else {
+ // Login without saving
+ await performLogin(credentials.username, credentials.password);
+ }
+ };
+
+ const performLogin = async (
+ username: string,
+ password: string,
+ options?: {
+ saveAccount?: boolean;
+ securityType?: AccountSecurityType;
+ pinCode?: string;
+ },
+ ) => {
setLoading(true);
try {
- const result = CredentialsSchema.safeParse(credentials);
- if (result.success) {
- await login(credentials.username, credentials.password);
- }
+ await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
@@ -113,6 +155,44 @@ const Login: React.FC = () => {
}
} finally {
setLoading(false);
+ setPendingLogin(null);
+ }
+ };
+
+ const handleSaveAccountConfirm = async (
+ securityType: AccountSecurityType,
+ pinCode?: string,
+ ) => {
+ setShowSaveModal(false);
+ if (pendingLogin) {
+ await performLogin(pendingLogin.username, pendingLogin.password, {
+ saveAccount: true,
+ securityType,
+ pinCode,
+ });
+ }
+ };
+
+ const handleQuickLoginWithSavedCredential = async (
+ serverUrl: string,
+ userId: string,
+ ) => {
+ await loginWithSavedCredential(serverUrl, userId);
+ };
+
+ const handlePasswordLogin = async (
+ serverUrl: string,
+ username: string,
+ password: string,
+ ) => {
+ await loginWithPassword(serverUrl, username, password);
+ };
+
+ const handleAddAccount = (server: SavedServer) => {
+ // Server is already selected, go to credential entry
+ setServer({ address: server.address });
+ if (server.name) {
+ setServerName(server.name);
}
};
@@ -262,19 +342,20 @@ const Login: React.FC = () => {
- setCredentials({ ...credentials, username: text })
+ setCredentials((prev) => ({ ...prev, username: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
- setCredentials({ ...credentials, username: newValue });
+ setCredentials((prev) => ({ ...prev, username: newValue }));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
- textContentType='oneTimeCode'
+ autoCorrect={false}
+ textContentType='username'
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
@@ -286,12 +367,12 @@ const Login: React.FC = () => {
- setCredentials({ ...credentials, password: text })
+ setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
- setCredentials({ ...credentials, password: newValue });
+ setCredentials((prev) => ({ ...prev, password: newValue }));
}
}}
value={credentials.password}
@@ -380,9 +461,12 @@ const Login: React.FC = () => {
}}
/>
{
+ onServerSelect={async (s) => {
await handleConnect(s.address);
}}
+ onQuickLogin={handleQuickLoginWithSavedCredential}
+ onPasswordLogin={handlePasswordLogin}
+ onAddAccount={handleAddAccount}
/>
@@ -398,8 +482,8 @@ const Login: React.FC = () => {
style={{ flex: 1 }}
>
{api?.basePath ? (
-
-
+
+
{serverName ? (
@@ -415,21 +499,23 @@ const Login: React.FC = () => {
- setCredentials({ ...credentials, username: text })
+ setCredentials((prev) => ({ ...prev, username: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
- setCredentials({ ...credentials, username: newValue });
+ setCredentials((prev) => ({
+ ...prev,
+ username: newValue,
+ }));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
- // Changed from username to oneTimeCode because it is a known issue in RN
- // https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
- textContentType='oneTimeCode'
+ autoCorrect={false}
+ textContentType='username'
clearButtonMode='while-editing'
maxLength={500}
/>
@@ -437,12 +523,15 @@ const Login: React.FC = () => {
- setCredentials({ ...credentials, password: text })
+ setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
- setCredentials({ ...credentials, password: newValue });
+ setCredentials((prev) => ({
+ ...prev,
+ password: newValue,
+ }));
}
}}
value={credentials.password}
@@ -454,6 +543,21 @@ const Login: React.FC = () => {
clearButtonMode='while-editing'
maxLength={500}
/>
+ setSaveAccount(!saveAccount)}
+ className='flex flex-row items-center py-2'
+ activeOpacity={0.7}
+ >
+
+
+ {t("save_account.save_for_later")}
+
+
)}
+
+ {/* Save Account Modal */}
+ {
+ setShowSaveModal(false);
+ setPendingLogin(null);
+ }}
+ onSave={handleSaveAccountConfirm}
+ username={pendingLogin?.username || credentials.username}
+ />
);
};
diff --git a/assets/Download_with_Obtainium.png b/assets/Download_with_Obtainium.png
new file mode 100644
index 00000000..a4cf4f9c
Binary files /dev/null and b/assets/Download_with_Obtainium.png differ
diff --git a/assets/icons/jellyseerr-logo.svg b/assets/icons/seerr-logo.svg
similarity index 100%
rename from assets/icons/jellyseerr-logo.svg
rename to assets/icons/seerr-logo.svg
diff --git a/assets/images/jellyseerr.PNG b/assets/images/seerr.PNG
similarity index 100%
rename from assets/images/jellyseerr.PNG
rename to assets/images/seerr.PNG
diff --git a/augmentations/index.ts b/augmentations/index.ts
index abec02c9..0c193e83 100644
--- a/augmentations/index.ts
+++ b/augmentations/index.ts
@@ -1,4 +1,3 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
-export * from "./string";
diff --git a/augmentations/number.ts b/augmentations/number.ts
index 11c0837d..9f251c5a 100644
--- a/augmentations/number.ts
+++ b/augmentations/number.ts
@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
- hoursToMilliseconds(): number;
}
}
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
-Number.prototype.hoursToMilliseconds = function () {
- return this.valueOf() * (60).minutesToMilliseconds();
-};
-
export {};
diff --git a/augmentations/string.ts b/augmentations/string.ts
deleted file mode 100644
index f4a50b55..00000000
--- a/augmentations/string.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-declare global {
- interface String {
- toTitle(): string;
- }
-}
-
-String.prototype.toTitle = function () {
- return this.replaceAll("_", " ").replace(
- /\w\S*/g,
- (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
- );
-};
-
-export {};
diff --git a/biome.json b/biome.json
index b94bda94..6f51bd9b 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"files": {
"includes": [
"**/*",
@@ -8,7 +8,8 @@
"!android",
"!Streamyfin.app",
"!utils/jellyseerr",
- "!.expo"
+ "!.expo",
+ "!docs/jellyfin-openapi-stable.json"
]
},
"linter": {
diff --git a/bun.lock b/bun.lock
index 42e697a9..9d9d2bac 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,110 +1,119 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "streamyfin",
"dependencies": {
- "@bottom-tabs/react-navigation": "^1.0.2",
+ "@bottom-tabs/react-navigation": "1.1.0",
+ "@douglowder/expo-av-route-picker-view": "^0.0.5",
"@expo/metro-runtime": "~6.1.1",
"@expo/react-native-action-sheet": "^4.1.1",
- "@expo/ui": "^0.2.0-beta.4",
+ "@expo/ui": "0.2.0-beta.9",
"@expo/vector-icons": "^15.0.3",
- "@gorhom/bottom-sheet": "^5.1.0",
+ "@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^11.4.1",
- "@react-navigation/material-top-tabs": "^7.2.14",
+ "@react-navigation/material-top-tabs": "7.4.9",
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "2.0.2",
- "@tanstack/react-query": "^5.90.7",
+ "@tanstack/query-sync-storage-persister": "^5.90.18",
+ "@tanstack/react-pacer": "^0.19.1",
+ "@tanstack/react-query": "5.90.12",
+ "@tanstack/react-query-persist-client": "^5.90.18",
"axios": "^1.7.9",
- "expo": "^54.0.23",
- "expo-application": "~7.0.5",
- "expo-asset": "~12.0.6",
- "expo-background-task": "~1.0.5",
- "expo-blur": "~15.0.5",
- "expo-brightness": "~14.0.5",
- "expo-build-properties": "~1.0.6",
- "expo-constants": "~18.0.10",
- "expo-dev-client": "~6.0.17",
- "expo-device": "~8.0.5",
- "expo-font": "~14.0.9",
- "expo-haptics": "~15.0.5",
- "expo-image": "~3.0.10",
- "expo-linear-gradient": "~15.0.5",
- "expo-linking": "~8.0.6",
- "expo-localization": "~17.0.5",
- "expo-notifications": "~0.32.7",
- "expo-router": "~6.0.14",
- "expo-screen-orientation": "~9.0.5",
- "expo-sensors": "~15.0.5",
- "expo-sharing": "~14.0.5",
- "expo-splash-screen": "~31.0.7",
- "expo-status-bar": "~3.0.6",
- "expo-system-ui": "~6.0.8",
- "expo-task-manager": "~14.0.8",
- "expo-web-browser": "~15.0.9",
+ "expo": "~54.0.31",
+ "expo-application": "~7.0.8",
+ "expo-asset": "~12.0.12",
+ "expo-background-task": "~1.0.10",
+ "expo-blur": "~15.0.8",
+ "expo-brightness": "~14.0.8",
+ "expo-build-properties": "~1.0.10",
+ "expo-constants": "18.0.13",
+ "expo-crypto": "^15.0.8",
+ "expo-dev-client": "~6.0.20",
+ "expo-device": "~8.0.10",
+ "expo-font": "~14.0.10",
+ "expo-haptics": "~15.0.8",
+ "expo-image": "~3.0.11",
+ "expo-linear-gradient": "~15.0.8",
+ "expo-linking": "~8.0.11",
+ "expo-localization": "~17.0.8",
+ "expo-location": "^19.0.8",
+ "expo-notifications": "~0.32.16",
+ "expo-router": "~6.0.21",
+ "expo-screen-orientation": "~9.0.8",
+ "expo-secure-store": "^15.0.8",
+ "expo-sharing": "~14.0.8",
+ "expo-splash-screen": "~31.0.13",
+ "expo-status-bar": "~3.0.9",
+ "expo-system-ui": "~6.0.9",
+ "expo-task-manager": "14.0.9",
+ "expo-web-browser": "~15.0.10",
"i18next": "^25.0.0",
- "jotai": "^2.12.5",
- "lodash": "^4.17.21",
+ "jotai": "2.16.1",
+ "lodash": "4.17.21",
"nativewind": "^2.0.11",
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
- "react-i18next": "16.0.0",
- "react-native": "npm:react-native-tvos@0.81.5-1",
+ "react-i18next": "16.5.2",
+ "react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0",
- "react-native-bottom-tabs": "^1.0.2",
+ "react-native-bottom-tabs": "1.1.0",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^15.0.0",
+ "react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
- "react-native-gesture-handler": "~2.28.0",
+ "react-native-gesture-handler": "2.28.0",
+ "react-native-glass-effect-view": "^1.0.0",
"react-native-google-cast": "^4.9.1",
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0",
- "react-native-mmkv": "4.0.0",
- "react-native-nitro-modules": "^0.31.5",
+ "react-native-mmkv": "4.1.1",
+ "react-native-nitro-modules": "0.32.1",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1",
- "react-native-reanimated-carousel": "4.0.2",
+ "react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.18.0",
"react-native-svg": "15.12.1",
+ "react-native-text-ticker": "^1.15.0",
+ "react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
- "react-native-video": "6.16.1",
"react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
- "sonner-native": "^0.21.0",
+ "sonner-native": "0.21.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
- "zod": "^4.1.3",
+ "zod": "4.1.13",
},
"devDependencies": {
"@babel/core": "7.28.5",
- "@biomejs/biome": "2.3.5",
- "@react-native-community/cli": "20.0.2",
+ "@biomejs/biome": "2.3.11",
+ "@react-native-community/cli": "20.1.0",
"@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14",
- "@types/lodash": "4.17.20",
- "@types/react": "~19.1.10",
+ "@types/lodash": "4.17.21",
+ "@types/react": "19.1.17",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
- "expo-doctor": "1.17.11",
+ "expo-doctor": "1.17.14",
"husky": "9.1.7",
- "lint-staged": "16.2.6",
- "react-test-renderer": "19.1.1",
+ "lint-staged": "16.2.7",
+ "react-test-renderer": "19.2.3",
"typescript": "5.9.3",
},
},
},
"overrides": {
- "expo-constants": "~18.0.10",
- "expo-task-manager": "~14.0.8",
+ "expo-constants": "18.0.13",
},
"packages": {
"@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="],
@@ -299,73 +308,73 @@
"@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=="],
- "@biomejs/biome": ["@biomejs/biome@2.3.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.5", "@biomejs/cli-darwin-x64": "2.3.5", "@biomejs/cli-linux-arm64": "2.3.5", "@biomejs/cli-linux-arm64-musl": "2.3.5", "@biomejs/cli-linux-x64": "2.3.5", "@biomejs/cli-linux-x64-musl": "2.3.5", "@biomejs/cli-win32-arm64": "2.3.5", "@biomejs/cli-win32-x64": "2.3.5" }, "bin": { "biome": "bin/biome" } }, "sha512-HvLhNlIlBIbAV77VysRIBEwp55oM/QAjQEin74QQX9Xb259/XP/D5AGGnZMOyF1el4zcvlNYYR3AyTMUV3ILhg=="],
+ "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-qpT8XDqeUlzrOW8zb4k3tjhT7rmvVRumhi2657I2aGcY4B+Ft5fNwDdZGACzn8zj7/K1fdWjgwYE3i2mSZ+vOA=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-eGUG7+hcLgGnMNl1KHVZUYxahYAhC462jF/wQolqu4qso2MSk32Q+QrpN7eN4jAHAg7FUMIo897muIhK4hXhqg=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-nUmR8gb6yvrKhtRgzwo/gDimPwnO5a4sCydf8ZS2kHIJhEmSmk+STsusr1LHTuM//wXppBawvSQi2xFXJCdgKQ=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="],
- "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.0.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-OrCw8s2NzFxO1TO5W2vyr7HNvh1Yjy00f72D/0BIPtImc0aj5CRrT9nFRE7YP0FWZb0AY5+0QU9jaoph1rBlSg=="],
+ "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.1.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-+4YppCodABcSNIgJiq95QUQ+3ClVBG+rLG3WmYI0+/nbxqKbCz6luFBep4KFOj98Iplj1JY2Ki6ix8CcOZVQ/Q=="],
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
+ "@douglowder/expo-av-route-picker-view": ["@douglowder/expo-av-route-picker-view@0.0.5", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-oT4wf8aYYNfLEuZEkwZIH7CtEHKnEHWnjs6/hNwbFGEC0FnfjjWBNrQEt4fo5/gkafqa2G5ILkxndMyBZvk5dg=="],
+
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
- "@expo/cli": ["@expo/cli@54.0.16", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devcert": "^1.1.2", "@expo/env": "~2.0.7", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@expo/mcp-tunnel": "~0.1.0", "@expo/metro": "~54.1.0", "@expo/metro-config": "~54.0.9", "@expo/osascript": "^2.3.7", "@expo/package-manager": "^1.9.8", "@expo/plist": "^0.4.7", "@expo/prebuild-config": "^54.0.6", "@expo/schema-utils": "^0.1.7", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.81.5", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "expo-server": "^1.0.4", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-hY/OdRaJMs5WsVPuVSZ+RLH3VObJmL/pv5CGCHEZHN2PxZjSZSdctyKV8UcFBXTF0yIKNAJ9XLs1dlNYXHh4Cw=="],
+ "@expo/cli": ["@expo/cli@54.0.21", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devcert": "^1.2.1", "@expo/env": "~2.0.8", "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@expo/metro": "~54.2.0", "@expo/metro-config": "~54.0.13", "@expo/osascript": "^2.3.8", "@expo/package-manager": "^1.9.9", "@expo/plist": "^0.4.8", "@expo/prebuild-config": "^54.0.8", "@expo/schema-utils": "^0.1.8", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.81.5", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "expo-server": "^1.0.5", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.5.2", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-L/FdpyZDsg/Nq6xW6kfiyF9DUzKfLZCKFXEVZcDqCNar6bXxQVotQyvgexRvtUF5nLinuT/UafLOdC3FUALUmA=="],
- "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="],
+ "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="],
- "@expo/config": ["@expo/config@12.0.10", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.2", "@expo/config-types": "^54.0.8", "@expo/json-file": "^10.0.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^10.4.2", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "3.35.0" } }, "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w=="],
+ "@expo/config": ["@expo/config@12.0.13", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.4", "@expo/config-types": "^54.0.10", "@expo/json-file": "^10.0.8", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "~3.35.1" } }, "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ=="],
- "@expo/config-plugins": ["@expo/config-plugins@54.0.2", "", { "dependencies": { "@expo/config-types": "^54.0.8", "@expo/json-file": "~10.0.7", "@expo/plist": "^0.4.7", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^10.4.2", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg=="],
+ "@expo/config-plugins": ["@expo/config-plugins@54.0.4", "", { "dependencies": { "@expo/config-types": "^54.0.10", "@expo/json-file": "~10.0.8", "@expo/plist": "^0.4.8", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q=="],
- "@expo/config-types": ["@expo/config-types@54.0.8", "", {}, "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A=="],
+ "@expo/config-types": ["@expo/config-types@54.0.10", "", {}, "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA=="],
- "@expo/devcert": ["@expo/devcert@1.2.0", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0", "glob": "^10.4.2" } }, "sha512-Uilcv3xGELD5t/b0eM4cxBFEKQRIivB3v7i+VhWLV/gL98aw810unLKKJbGAxAIhY6Ipyz8ChWibFsKFXYwstA=="],
+ "@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="],
- "@expo/devtools": ["@expo/devtools@0.1.7", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-dfIa9qMyXN+0RfU6SN4rKeXZyzKWsnz6xBSDccjL4IRiE+fQ0t84zg0yxgN4t/WK2JU5v6v4fby7W7Crv9gJvA=="],
+ "@expo/devtools": ["@expo/devtools@0.1.8", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ=="],
- "@expo/env": ["@expo/env@2.0.7", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-BNETbLEohk3HQ2LxwwezpG8pq+h7Fs7/vAMP3eAtFT1BCpprLYoBBFZH7gW4aqGfqOcVP4Lc91j014verrYNGg=="],
+ "@expo/env": ["@expo/env@2.0.8", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA=="],
- "@expo/fingerprint": ["@expo/fingerprint@0.15.3", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^10.4.2", "ignore": "^5.3.1", "minimatch": "^9.0.0", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-8YPJpEYlmV171fi+t+cSLMX1nC5ngY9j2FiN70dHldLpd6Ct6ouGhk96svJ4BQZwsqwII2pokwzrDAwqo4Z0FQ=="],
+ "@expo/fingerprint": ["@expo/fingerprint@0.15.4", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^9.0.0", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng=="],
- "@expo/image-utils": ["@expo/image-utils@0.8.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "resolve-global": "^1.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-SXOww4Wq3RVXLyOaXiCCuQFguCDh8mmaHBv54h/R29wGl4jRY8GEyQEx8SypV/iHt1FbzsU/X3Qbcd9afm2W2w=="],
+ "@expo/image-utils": ["@expo/image-utils@0.8.8", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "resolve-global": "^1.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA=="],
- "@expo/json-file": ["@expo/json-file@10.0.7", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw=="],
+ "@expo/json-file": ["@expo/json-file@10.0.8", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ=="],
- "@expo/mcp-tunnel": ["@expo/mcp-tunnel@0.1.0", "", { "dependencies": { "ws": "^8.18.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.13.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-rJ6hl0GnIZj9+ssaJvFsC7fwyrmndcGz+RGFzu+0gnlm78X01957yjtHgjcmnQAgL5hWEOR6pkT0ijY5nU5AWw=="],
+ "@expo/metro": ["@expo/metro@54.2.0", "", { "dependencies": { "metro": "0.83.3", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-config": "0.83.3", "metro-core": "0.83.3", "metro-file-map": "0.83.3", "metro-minify-terser": "0.83.3", "metro-resolver": "0.83.3", "metro-runtime": "0.83.3", "metro-source-map": "0.83.3", "metro-symbolicate": "0.83.3", "metro-transform-plugins": "0.83.3", "metro-transform-worker": "0.83.3" } }, "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w=="],
- "@expo/metro": ["@expo/metro@54.1.0", "", { "dependencies": { "metro": "0.83.2", "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-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2" } }, "sha512-MgdeRNT/LH0v1wcO0TZp9Qn8zEF0X2ACI0wliPtv5kXVbXWI+yK9GyrstwLAiTXlULKVIg3HVSCCvmLu0M3tnw=="],
-
- "@expo/metro-config": ["@expo/metro-config@54.0.9", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~12.0.10", "@expo/env": "~2.0.7", "@expo/json-file": "~10.0.7", "@expo/metro": "~54.1.0", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^10.4.2", "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-CRI4WgFXrQ2Owyr8q0liEBJveUIF9DcYAKadMRsJV7NxGNBdrIIKzKvqreDfsGiRqivbLsw6UoNb3UE7/SvPfg=="],
+ "@expo/metro-config": ["@expo/metro-config@54.0.13", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~12.0.13", "@expo/env": "~2.0.8", "@expo/json-file": "~10.0.8", "@expo/metro": "~54.2.0", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-RRufMCgLR2Za1WGsh02OatIJo5qZFt31yCnIOSfoubNc3Qqe92Z41pVsbrFnmw5CIaisv1NgdBy05DHe7pEyuw=="],
"@expo/metro-runtime": ["@expo/metro-runtime@6.1.2", "", { "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g=="],
- "@expo/osascript": ["@expo/osascript@2.3.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-IClSOXxR0YUFxIriUJVqyYki7lLMIHrrzOaP01yxAL1G8pj2DWV5eW1y5jSzIcIfSCNhtGsshGd1tU/AYup5iQ=="],
+ "@expo/osascript": ["@expo/osascript@2.3.8", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w=="],
- "@expo/package-manager": ["@expo/package-manager@1.9.8", "", { "dependencies": { "@expo/json-file": "^10.0.7", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-4/I6OWquKXYnzo38pkISHCOCOXxfeEmu4uDoERq1Ei/9Ur/s9y3kLbAamEkitUkDC7gHk1INxRWEfFNzGbmOrA=="],
+ "@expo/package-manager": ["@expo/package-manager@1.9.9", "", { "dependencies": { "@expo/json-file": "^10.0.8", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-Nv5THOwXzPprMJwbnXU01iXSrCp3vJqly9M4EJ2GkKko9Ifer2ucpg7x6OUsE09/lw+npaoUnHMXwkw7gcKxlg=="],
- "@expo/plist": ["@expo/plist@0.4.7", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA=="],
+ "@expo/plist": ["@expo/plist@0.4.8", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ=="],
- "@expo/prebuild-config": ["@expo/prebuild-config@54.0.6", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/config-types": "^54.0.8", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-xowuMmyPNy+WTNq+YX0m0EFO/Knc68swjThk4dKivgZa8zI1UjvFXOBIOp8RX4ljCXLzwxQJM5oBBTvyn+59ZA=="],
+ "@expo/prebuild-config": ["@expo/prebuild-config@54.0.8", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/config-types": "^54.0.10", "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg=="],
"@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.1", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-4KRaba2vhqDRR7ObBj6nrD5uJw8ePoNHdIOMETTpgGTX7StUbrF4j/sfrP1YUyaPEa1P8FXdwG6pB+2WtrJd1A=="],
- "@expo/schema-utils": ["@expo/schema-utils@0.1.7", "", {}, "sha512-jWHoSuwRb5ZczjahrychMJ3GWZu54jK9ulNdh1d4OzAEq672K9E5yOlnlBsfIHWHGzUAT+0CL7Yt1INiXTz68g=="],
+ "@expo/schema-utils": ["@expo/schema-utils@0.1.8", "", {}, "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A=="],
"@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="],
@@ -373,7 +382,7 @@
"@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="],
- "@expo/ui": ["@expo/ui@0.2.0-canary-20251031-b135dff", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-L/TEKnv/hpQ/Q1sO8lJw0wxdcv88UoA1JShwRSYHLN88UstjxvBNvMqlKGk7SNkTUJtlrttWAundJA4jM2mDPw=="],
+ "@expo/ui": ["@expo/ui@0.2.0-beta.9", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-RaBcp0cMe5GykQogJwRZGy4o4JHDLtrr+HaurDPhwPKqVATsV0rR11ysmFe4QX8XWLP/L3od7NOkXUi5ailvaw=="],
"@expo/vector-icons": ["@expo/vector-icons@15.0.3", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-SBUyYKphmlfUBqxSfDdJ3jAdEVSALS2VUPOUyqn48oZmb2TL/O7t7/PQm5v4NQujYEPLPMTLn9KVw6H7twwbTA=="],
@@ -381,7 +390,7 @@
"@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="],
- "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.6", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ=="],
+ "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
@@ -391,6 +400,10 @@
"@ide/backoff": ["@ide/backoff@1.0.0", "", {}, "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g=="],
+ "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
+
+ "@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@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=="],
@@ -499,36 +512,34 @@
"@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.0.2", "", { "dependencies": { "@react-native-community/cli-clean": "20.0.2", "@react-native-community/cli-config": "20.0.2", "@react-native-community/cli-doctor": "20.0.2", "@react-native-community/cli-server-api": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "@react-native-community/cli-types": "20.0.2", "chalk": "^4.1.2", "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", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-ocgRFKRLX8b5rEK38SJfpr0AMl6SqseWljk6c5LxCG/zpCfPPNQdXq1OsDvmEwsqO4OEQ6tmOaSm9OgTm6FhbQ=="],
+ "@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.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-hfbC69fTD0fqZCCep8aqnVztBXUhAckNhi76lEV7USENtgBRwNq2s1wATgKAzOhxKuAL9TEkf5TZ/Dhp/YLhCQ=="],
+ "@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.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1" } }, "sha512-OuSAyqTv0MBbRqSyO+80IKasHnwLESydZBTrLjIGwGhDokMH07mZo8Io2H8X300WWa57LC2L8vQf73TzGS3ikQ=="],
+ "@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.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1" } }, "sha512-5yZ2Grr89omnMptV36ilV4EIrRLrIYQAsTTVU/hNI2vL7lz6WB8rPhP5QuovXk3TIjl1Wz2r9A6ZNO2SNJ8nig=="],
+ "@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.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-6MLL9Duu/JytqI6XfYuc78LSkRGfJoCqTSfqTJzBNSnz6S7XJps9spGBlgvrGh/j0howBpQlFH0J8Ws4N4mCxA=="],
+ "@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.0.2", "", { "dependencies": { "@react-native-community/cli-config": "20.0.2", "@react-native-community/cli-platform-android": "20.0.2", "@react-native-community/cli-platform-apple": "20.0.2", "@react-native-community/cli-platform-ios": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "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", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-PQ8BdoNDE2OaMGLH66HZE7FV4qj0iWBHi0lkPUTb8eJJ+vlvzUtBf0N9QSv2TAzFjA59a2FElk6jBWnDC/ql1A=="],
+ "@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.0.2", "", { "dependencies": { "@react-native-community/cli-config-android": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "logkitty": "^0.7.1" } }, "sha512-Wo2AIkdv3PMEMT4k7QiNm3smNpWK6rd+glVH4Nm6Hco1EgLQ4I9x+gwcS1yN53UHYtq9YnguDCXk2L8duUESDQ=="],
+ "@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.0.2", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1" } }, "sha512-PdsQVFLY+wGnAN1kZ38XzzWiUlqaG1cXdpkQ1rYaiiNu3PVTc2/KtteLcPG/wbApbfoPggQ/ffh+JGg7NL+HNw=="],
+ "@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.0.2", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.0.2" } }, "sha512-bVOqLsBztT+xVV65uztJ7R/dtjj4vaPXJU1RLi35zLtr1APAxzf+2ydiixxtBjNFylM3AZlF8iL5WXjeWVqrmA=="],
+ "@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.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "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-u4tUzWnc+qthaDvd1NxdCqCNMY7Px6dAH1ODAXMtt+N27llGMJOl0J3slMx03dScftOWbGM61KA5cCpaxphYVQ=="],
+ "@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.0.2", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "chalk": "^4.1.2", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-bPYhRYggW9IIM8pvrZF/0r6HaxCyEWDn6zfPQPMWlkQUwkzFZ8GBY/M7yiHgDzozWKPT4DqZPumrq806Vcksow=="],
+ "@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.0.2", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-OZzy6U4M8Szg8iiF459OoTjRKggxLrdhZVHKfRhrAUfojhjRiWbJNkkPxJtOIPeNSgsB0heizgpE4QwCgnYeuQ=="],
+ "@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-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-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-1", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-v77jJvzH2jzMj3G8pthdaRjiUhmdQ3S/OGiTX45Tn1J+whLaPOEkVRCel9xPHhrTPIEwrOOwGNiAFN/s1hzWZA=="],
-
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
"@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="],
@@ -549,13 +560,15 @@
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
+ "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.81.5", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw=="],
+
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
- "@react-navigation/elements": ["@react-navigation/elements@2.8.1", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-MLmuS5kPAeAFFOylw89WGjgEFBqGj/KBK6ZrFrAOqLnTqEzk52/SO1olb5GB00k6ZUCDZKJOp1BrLXslxE6TgQ=="],
+ "@react-navigation/elements": ["@react-navigation/elements@2.9.2", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-J1GltOAGowNLznEphV/kr4zs0U7mUBO1wVA2CqpkN8ePBsoxrAmsd+T5sEYUCXN9KgTDFvc6IfcDqrGSQngd/g=="],
- "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.2", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-LB/bCDhdaKsexA5w0otgZEDBysGbiCr2l0hW6z41rJQ0JqAOVybH0cBuFr3Awasv0mQh9iTJNha4VsuUb7Q0Xw=="],
+ "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.9", "", { "dependencies": { "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-oYpdTfa2D1Tn0HJER9dRCR260agKGgYe+ydSHt3RIsJ9sLg8hU7ntKYWo1FnEC/Nsv1/N1u/tRst7ZpQRjjl4A=="],
"@react-navigation/native": ["@react-navigation/native@7.1.19", "", { "dependencies": { "@react-navigation/core": "^7.13.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-fM7q8di4Q8sp2WUhiUWOe7bEDRyRhbzsKQOd5N2k+lHeCx3UncsRYuw4Q/KN0EovM3wWKqMMmhy/YWuEO04kgw=="],
@@ -577,9 +590,25 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
- "@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="],
+ "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
- "@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="],
+ "@tanstack/pacer": ["@tanstack/pacer@0.17.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-52GytGu07L73lNCWB1N02NWBp/tzK2jZ20U8sFInXyiq2KHtHxbXaN1Qw/MR1REqFIKgEy5DOBNZRjuSy5zaRg=="],
+
+ "@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
+
+ "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.91.15", "", { "dependencies": { "@tanstack/query-core": "5.90.16" } }, "sha512-vnPSfQVo41EKJN8v20nkhWNZPyB1dMJIy5icOvCGzcCJzsmRefYY1owtr63ICOcjOiPPTuNEfPsdjdBhkzYnmA=="],
+
+ "@tanstack/query-sync-storage-persister": ["@tanstack/query-sync-storage-persister@5.90.18", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "@tanstack/query-persist-client-core": "5.91.15" } }, "sha512-tKngFopz/TuAe7LBDg7IOhWPh9blxdQ6QG/vVL2dFzRmlPNcSo4WdCSONqSDioJkcyTwh1YCSlcikmJ1WnSb3Q=="],
+
+ "@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.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
+
+ "@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-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
+
+ "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@@ -605,7 +634,7 @@
"@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="],
- "@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
+ "@types/lodash": ["@types/lodash@4.17.21", "", {}, "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ=="],
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
@@ -671,7 +700,7 @@
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
@@ -723,7 +752,7 @@
"babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="],
- "babel-preset-expo": ["babel-preset-expo@54.0.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.81.5", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.29.1", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo"] }, "sha512-JENWk0bvxW4I1ftveO8GRtX2t2TH6N4Z0TPvIHxroZ/4SswUfyNsUNbbP7Fm4erj3ar/JHGri5kTZ+s3xdjHZw=="],
+ "babel-preset-expo": ["babel-preset-expo@54.0.9", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.81.5", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.29.1", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo"] }, "sha512-8J6hRdgEC2eJobjoft6mKJ294cLxmi3khCUy2JJQp4htOYYkllSLUq6vudWJkTJiIuGdVR4bR6xuz2EvJLWHNg=="],
"babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="],
@@ -973,81 +1002,85 @@
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
- "expo": ["expo@54.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.16", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devtools": "0.1.7", "@expo/fingerprint": "0.15.3", "@expo/metro": "~54.1.0", "@expo/metro-config": "54.0.9", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.7", "expo-asset": "~12.0.9", "expo-constants": "~18.0.10", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-keep-awake": "~15.0.7", "expo-modules-autolinking": "3.0.21", "expo-modules-core": "3.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ=="],
+ "expo": ["expo@54.0.31", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.21", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.2.0", "@expo/metro-config": "54.0.13", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.9", "expo-asset": "~12.0.12", "expo-constants": "~18.0.13", "expo-file-system": "~19.0.21", "expo-font": "~14.0.10", "expo-keep-awake": "~15.0.8", "expo-modules-autolinking": "3.0.24", "expo-modules-core": "3.0.29", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-kQ3RDqA/a59I7y+oqQGyrPbbYlgPMUdKBOgvFLpoHbD2bCM+F75i4N0mUijy7dG5F/CUCu2qHmGGUCXBbMDkCg=="],
- "expo-application": ["expo-application@7.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg=="],
+ "expo-application": ["expo-application@7.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q=="],
- "expo-asset": ["expo-asset@12.0.9", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "expo-constants": "~18.0.9" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-vrdRoyhGhBmd0nJcssTSk1Ypx3Mbn/eXaaBCQVkL0MJ8IOZpAObAjfD5CTy8+8RofcHEQdh3wwZVCs7crvfOeg=="],
+ "expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="],
- "expo-background-task": ["expo-background-task@1.0.8", "", { "dependencies": { "expo-task-manager": "~14.0.7" }, "peerDependencies": { "expo": "*" } }, "sha512-G6WnljBhO0K9j0ntmytF5rZLtYUpwh8n2+hcgmxM1ISPAVVZSPHZhkF9YjBOKpdPWZxmukBgEwejfcGckb8TQQ=="],
+ "expo-background-task": ["expo-background-task@1.0.10", "", { "dependencies": { "expo-task-manager": "~14.0.9" }, "peerDependencies": { "expo": "*" } }, "sha512-EbPnuf52Ps/RJiaSFwqKGT6TkvMChv7bI0wF42eADbH3J2EMm5y5Qvj0oFmF1CBOwc3mUhqj63o7Pl6OLkGPZQ=="],
- "expo-blur": ["expo-blur@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-SugQQbQd+zRPy8z2G5qDD4NqhcD7srBF7fN7O7yq6q7ZFK59VWvpDxtMoUkmSfdxgqONsrBN/rLdk00USADrMg=="],
+ "expo-blur": ["expo-blur@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w=="],
- "expo-brightness": ["expo-brightness@14.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-wccb/NdQEd45UF0lgNEksZt3E8uzlIcxIx1ZqZYWbHyNvcS3LUj5wxB6+ZgKTLeWu4vLQ+oHe+F0QrkC9ojrig=="],
+ "expo-brightness": ["expo-brightness@14.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-WOg3UxzkHFTKBW3XvROlrVRmnJmZLhGBGd1RdzTfrtt2/MdSzvVmCevqWh4bohkeLABh0Yc9YRo1vFgfT73DWw=="],
- "expo-build-properties": ["expo-build-properties@1.0.9", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-2icttCy3OPTk/GWIFt+vwA+0hup53jnmYb7JKRbvNvrrOrz+WblzpeoiaOleI2dYG/vjwpNO8to8qVyKhYJtrQ=="],
+ "expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="],
- "expo-constants": ["expo-constants@18.0.10", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/env": "~2.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw=="],
+ "expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="],
- "expo-dev-client": ["expo-dev-client@6.0.17", "", { "dependencies": { "expo-dev-launcher": "6.0.17", "expo-dev-menu": "7.0.16", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.8", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-zVilIum3sqXFbhYhPT6TuxR3ddH/IfHL82FiOTqJUiYaTQqun1I6ogSvU1djhY1eXUYhfYIBieQNWMVjXPxMvw=="],
+ "expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="],
- "expo-dev-launcher": ["expo-dev-launcher@6.0.17", "", { "dependencies": { "expo-dev-menu": "7.0.16", "expo-manifests": "~1.0.8" }, "peerDependencies": { "expo": "*" } }, "sha512-riLxFXaw6Nvgb27TiQtUvoHkW/zTz0aO7M+qxDBBaEbJMJSFl51KSwOJJBTItVQIE9f9jB8x5L1CfLw81/McZw=="],
+ "expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="],
- "expo-dev-menu": ["expo-dev-menu@7.0.16", "", { "dependencies": { "expo-dev-menu-interface": "2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-/kjTjk5tcZV0ixYnV3JyzPXKlMimpBNYaDo4XxBbRFIkTf/vmb/9e1BTR2nALnoa/D3MRwtR43gZYT+W/wfKXw=="],
+ "expo-dev-launcher": ["expo-dev-launcher@6.0.20", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.0.18", "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA=="],
+
+ "expo-dev-menu": ["expo-dev-menu@7.0.18", "", { "dependencies": { "expo-dev-menu-interface": "2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA=="],
"expo-dev-menu-interface": ["expo-dev-menu-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw=="],
- "expo-device": ["expo-device@8.0.9", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-XqRpaljDNAYZGZzMpC+b9KZfzfydtkwx3pJAp6ODDH+O/5wjAw+mLc5wQMGJCx8/aqVmMsAokec7iebxDPFZDA=="],
+ "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.17.11", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-4eYZPJm4op2aRQWvd6RA6dZt1mVQQe79n7iqqFi6P927K8w2ld8kZ2D7m/4ahjj9/HBW9NS98m4qGomKJFDuPg=="],
+ "expo-doctor": ["expo-doctor@1.17.14", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-+UsXFP5ZTVobDuGS5Du8aKU6O6s2sa49QOdGHdzP8UEjQKH8gPb59uw6hxEQmo6YtVboLwQd13QEdcSolBMvLw=="],
- "expo-file-system": ["expo-file-system@19.0.17", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-WwaS01SUFrxBnExn87pg0sCTJjZpf2KAOzfImG0o8yhkU7fbYpihpl/oocXBEsNbj58a8hVt1Y4CVV5c1tzu/g=="],
+ "expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="],
- "expo-font": ["expo-font@14.0.9", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg=="],
+ "expo-font": ["expo-font@14.0.10", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q=="],
- "expo-haptics": ["expo-haptics@15.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ=="],
+ "expo-haptics": ["expo-haptics@15.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g=="],
- "expo-image": ["expo-image@3.0.10", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ=="],
+ "expo-image": ["expo-image@3.0.11", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA=="],
"expo-json-utils": ["expo-json-utils@0.15.0", "", {}, "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ=="],
- "expo-keep-awake": ["expo-keep-awake@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CgBNcWVPnrIVII5G54QDqoE125l+zmqR4HR8q+MQaCfHet+dYpS5vX5zii/RMayzGN4jPgA4XYIQ28ePKFjHoA=="],
+ "expo-keep-awake": ["expo-keep-awake@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ=="],
- "expo-linear-gradient": ["expo-linear-gradient@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA=="],
+ "expo-linear-gradient": ["expo-linear-gradient@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw=="],
- "expo-linking": ["expo-linking@8.0.8", "", { "dependencies": { "expo-constants": "~18.0.8", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg=="],
+ "expo-linking": ["expo-linking@8.0.11", "", { "dependencies": { "expo-constants": "~18.0.12", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA=="],
- "expo-localization": ["expo-localization@17.0.7", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA=="],
+ "expo-localization": ["expo-localization@17.0.8", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g=="],
- "expo-manifests": ["expo-manifests@1.0.8", "", { "dependencies": { "@expo/config": "~12.0.8", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-nA5PwU2uiUd+2nkDWf9e71AuFAtbrb330g/ecvuu52bmaXtN8J8oiilc9BDvAX0gg2fbtOaZdEdjBYopt1jdlQ=="],
+ "expo-location": ["expo-location@19.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA=="],
- "expo-modules-autolinking": ["expo-modules-autolinking@3.0.21", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-pOtPDLln3Ju8DW1zRW4OwZ702YqZ8g+kM/tEY1sWfv22kWUtxkvK+ytRDRpRdnKEnC28okbhWqeMnmVkSFzP6Q=="],
+ "expo-manifests": ["expo-manifests@1.0.10", "", { "dependencies": { "@expo/config": "~12.0.11", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ=="],
- "expo-modules-core": ["expo-modules-core@3.0.25", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0P8PT8UV6c5/+p8zeVM/FXvBgn/ErtGcMaasqUgbzzBUg94ktbkIrij9t9reGCrir03BYt/Bcpv+EQtYC8JOug=="],
+ "expo-modules-autolinking": ["expo-modules-autolinking@3.0.24", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ=="],
- "expo-notifications": ["expo-notifications@0.32.12", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~7.0.7", "expo-constants": "~18.0.9" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-FVJ5W4rOpKvmrLJ1Sd5pxiVTV4a7ApgTlKro+E5X8M2TBbXmEVOjs09klzdalXTjlzmU/Gu8aRw9xr7Ea/gZdw=="],
+ "expo-modules-core": ["expo-modules-core@3.0.29", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q=="],
- "expo-router": ["expo-router@6.0.14", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.7", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-server": "^1.0.3", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.10", "expo-linking": "^8.0.8", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": ">= 19.0.0" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-vizLO4SgnMEL+PPs2dXr+etEOuksjue7yUQBCtfCEdqoDkQlB0r35zI7rS34Wt53sxKWSlM2p+038qQEpxtiFw=="],
+ "expo-notifications": ["expo-notifications@0.32.16", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~7.0.8", "expo-constants": "~18.0.13" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw=="],
- "expo-screen-orientation": ["expo-screen-orientation@9.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-UH/XlB9eMw+I2cyHSkXhAHRAPk83WyA3k5bst7GLu14wRuWiTch9fb6I7qEJK5CN6+XelcWxlBJymys6Fr/FKA=="],
+ "expo-router": ["expo-router@6.0.21", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.8", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-server": "^1.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.12", "expo-linking": "^8.0.11", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.3 || ~19.1.4 || ~19.2.3" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-wjTUjrnWj6gRYjaYl1kYfcRnNE4ZAQ0kz0+sQf6/mzBd/OU6pnOdD7WrdAW3pTTpm52Q8sMoeX98tNQEddg2uA=="],
- "expo-sensors": ["expo-sensors@15.0.7", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-TGUxRx/Ss7KGgfWo453YF64ENucw6oYryPiu/8I3ZZuf114xQPRxAbsZohPLaVUUGuaUyWbDsb0eRsmuKUzBnQ=="],
+ "expo-screen-orientation": ["expo-screen-orientation@9.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-qRoPi3E893o3vQHT4h1NKo51+7g2hjRSbDeg1fsSo/u2pOW5s6FCeoacLvD+xofOP33cH2MkE4ua54aWWO7Icw=="],
- "expo-server": ["expo-server@1.0.4", "", {}, "sha512-IN06r3oPxFh3plSXdvBL7dx0x6k+0/g0bgxJlNISs6qL5Z+gyPuWS750dpTzOeu37KyBG0RcyO9cXUKzjYgd4A=="],
+ "expo-secure-store": ["expo-secure-store@15.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw=="],
- "expo-sharing": ["expo-sharing@14.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g=="],
+ "expo-server": ["expo-server@1.0.5", "", {}, "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA=="],
- "expo-splash-screen": ["expo-splash-screen@31.0.10", "", { "dependencies": { "@expo/prebuild-config": "^54.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-i6g9IK798mae4yvflstQ1HkgahIJ6exzTCTw4vEdxV0J2SwiW3Tj+CwRjf0te7Zsb+7dDQhBTmGZwdv00VER2A=="],
+ "expo-sharing": ["expo-sharing@14.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q=="],
- "expo-status-bar": ["expo-status-bar@3.0.8", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-L248XKPhum7tvREoS1VfE0H6dPCaGtoUWzRsUv7hGKdiB4cus33Rc0sxkWkoQ77wE8stlnUlL5lvmT0oqZ3ZBw=="],
+ "expo-splash-screen": ["expo-splash-screen@31.0.13", "", { "dependencies": { "@expo/prebuild-config": "^54.0.8" }, "peerDependencies": { "expo": "*" } }, "sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA=="],
- "expo-system-ui": ["expo-system-ui@6.0.8", "", { "dependencies": { "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-DzJYqG2fibBSLzPDL4BybGCiilYOtnI1OWhcYFwoM4k0pnEzMBt1Vj8Z67bXglDDuz2HCQPGNtB3tQft5saKqQ=="],
+ "expo-status-bar": ["expo-status-bar@3.0.9", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw=="],
- "expo-task-manager": ["expo-task-manager@14.0.8", "", { "dependencies": { "unimodules-app-loader": "~6.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-HxhyvmulM8px+LQvqIKS85KVx2UodZf5RO+FE2ltpC4mQ5IFkX/ESqiK0grzDa4pVFLyxvs8LjuUKsfB5c39PQ=="],
+ "expo-system-ui": ["expo-system-ui@6.0.9", "", { "dependencies": { "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg=="],
+
+ "expo-task-manager": ["expo-task-manager@14.0.9", "", { "dependencies": { "unimodules-app-loader": "~6.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA=="],
"expo-updates-interface": ["expo-updates-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg=="],
- "expo-web-browser": ["expo-web-browser@15.0.9", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg=="],
+ "expo-web-browser": ["expo-web-browser@15.0.10", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
@@ -1071,6 +1104,8 @@
"fbjs-css-vars": ["fbjs-css-vars@1.0.2", "", {}, "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="],
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -1271,7 +1306,7 @@
"joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="],
- "jotai": ["jotai@2.15.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-yHT1HAZ3ba2Q8wgaUQ+xfBzEtcS8ie687I8XVCBinfg4bNniyqLIN+utPXWKQE93LMF5fPbQSVRZqgpcN5yd6Q=="],
+ "jotai": ["jotai@2.16.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-vrHcAbo3P7Br37C8Bv6JshMtlKMPqqmx0DDREtTjT4nf3QChDrYdbH+4ik/9V0cXA57dK28RkJ5dctYvavcIlg=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
@@ -1335,7 +1370,7 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
- "lint-staged": ["lint-staged@16.2.6", "", { "dependencies": { "commander": "^14.0.1", "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-s1gphtDbV4bmW1eylXpVMk2u7is7YsrLl8hzrtvC70h4ByhcMLZFY01Fx05ZUDNuv1H8HO4E+e2zgejV1jVwNw=="],
+ "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@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=="],
@@ -1373,23 +1408,23 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
- "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=="],
+ "metro": ["metro@0.83.3", "", { "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.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-config": "0.83.3", "metro-core": "0.83.3", "metro-file-map": "0.83.3", "metro-resolver": "0.83.3", "metro-runtime": "0.83.3", "metro-source-map": "0.83.3", "metro-symbolicate": "0.83.3", "metro-transform-plugins": "0.83.3", "metro-transform-worker": "0.83.3", "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-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q=="],
- "metro-babel-transformer": ["metro-babel-transformer@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-rirY1QMFlA1uxH3ZiNauBninwTioOgwChnRdDcbB4tgRZ+bGX9DiXoh9QdpppiaVKXdJsII932OwWXGGV4+Nlw=="],
+ "metro-babel-transformer": ["metro-babel-transformer@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g=="],
- "metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="],
+ "metro-cache": ["metro-cache@0.83.3", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.3" } }, "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q=="],
- "metro-cache-key": ["metro-cache-key@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-3EMG/GkGKYoTaf5RqguGLSWRqGTwO7NQ0qXKmNBjr0y6qD9s3VBXYlwB+MszGtmOKsqE9q3FPrE5Nd9Ipv7rZw=="],
+ "metro-cache-key": ["metro-cache-key@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw=="],
- "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=="],
+ "metro-config": ["metro-config@0.83.3", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.3", "metro-cache": "0.83.3", "metro-core": "0.83.3", "metro-runtime": "0.83.3", "yaml": "^2.6.1" } }, "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA=="],
- "metro-core": ["metro-core@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.2" } }, "sha512-8DRb0O82Br0IW77cNgKMLYWUkx48lWxUkvNUxVISyMkcNwE/9ywf1MYQUE88HaKwSrqne6kFgCSA/UWZoUT0Iw=="],
+ "metro-core": ["metro-core@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.3" } }, "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw=="],
- "metro-file-map": ["metro-file-map@0.83.2", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-cMSWnEqZrp/dzZIEd7DEDdk72PXz6w5NOKriJoDN9p1TDQ5nAYrY2lHi8d6mwbcGLoSlWmpPyny9HZYFfPWcGQ=="],
+ "metro-file-map": ["metro-file-map@0.83.3", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA=="],
- "metro-minify-terser": ["metro-minify-terser@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw=="],
+ "metro-minify-terser": ["metro-minify-terser@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ=="],
- "metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="],
+ "metro-resolver": ["metro-resolver@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ=="],
"metro-runtime": ["metro-runtime@0.83.3", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw=="],
@@ -1397,9 +1432,9 @@
"metro-symbolicate": ["metro-symbolicate@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.3", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw=="],
- "metro-transform-plugins": ["metro-transform-plugins@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-5WlW25WKPkiJk2yA9d8bMuZrgW7vfA4f4MBb9ZeHbTB3eIAoNN8vS8NENgG/X/90vpTB06X66OBvxhT3nHwP6A=="],
+ "metro-transform-plugins": ["metro-transform-plugins@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A=="],
- "metro-transform-worker": ["metro-transform-worker@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-minify-terser": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "nullthrows": "^1.1.1" } }, "sha512-G5DsIg+cMZ2KNfrdLnWMvtppb3+Rp1GMyj7Bvd9GgYc/8gRmvq1XVEF9XuO87Shhb03kFhGqMTgZerz3hZ1v4Q=="],
+ "metro-transform-worker": ["metro-transform-worker@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.3", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-minify-terser": "0.83.3", "metro-source-map": "0.83.3", "metro-transform-plugins": "0.83.3", "nullthrows": "^1.1.1" } }, "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -1441,7 +1476,7 @@
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
- "node-forge": ["node-forge@1.3.1", "", {}, "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="],
+ "node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="],
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
@@ -1517,7 +1552,7 @@
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
- "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=="],
+ "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
@@ -1605,15 +1640,15 @@
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
- "react-i18next": ["react-i18next@16.0.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q=="],
+ "react-i18next": ["react-i18next@16.5.2", "", { "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-GG/SBVxx9dvrO1uCs8VYdKfOP8NEBUhNP+2VDQLCifRJ8DL1qPq296k2ACNGyZMDe7iyIlz/LMJTQOs8HXSRvw=="],
- "react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
+ "react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
- "react-native": ["react-native-tvos@0.81.5-1", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-1", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-jEZ5S8Urjaxkb/pQsfxXslTtKGfeBdaXwEObTyAF3PvCT0wYKD4NbftVJC5Iid9/jKeoBfWTuAOTFfaivqx7IA=="],
+ "react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="],
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
- "react-native-bottom-tabs": ["react-native-bottom-tabs@1.0.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-eWNuTpJVefKRaROda4ZeWHvW1cUEb0mw8L7FyLEcPPsd7Tp3rfLRrhptl/O/3mAki9gvpzYE8ASE3GwUrjfp+Q=="],
+ "react-native-bottom-tabs": ["react-native-bottom-tabs@1.1.0", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ=="],
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
@@ -1623,10 +1658,14 @@
"react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
+ "react-native-draggable-flatlist": ["react-native-draggable-flatlist@4.0.3", "", { "dependencies": { "@babel/preset-typescript": "^7.17.12" }, "peerDependencies": { "react-native": ">=0.64.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=2.8.0" } }, "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw=="],
+
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="],
"react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="],
+ "react-native-glass-effect-view": ["react-native-glass-effect-view@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ABYG0oIiqbXsxe2R/cMhNgDn3YgwDLz/2TIN2XOxQopXC+MiGsG9C32VYQvO2sYehcu5JmI3h3EzwLwl6lJhhA=="],
+
"react-native-google-cast": ["react-native-google-cast@4.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/HvIKAaWHtG6aTNCxrNrqA2ftWGkfH0M/2iN+28pdGUXpKmueb33mgL1m8D4zzwEODQMcmpfoCsym1IwDvugBQ=="],
"react-native-image-colors": ["react-native-image-colors@2.5.0", "", { "dependencies": { "node-vibrant": "^4.0.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-3zSDgNj5HaZ0PDWaXkc4BpWpZRM5N4gBsoPC7DBfM/+op69Yvwbc0S1T7CnxBWbvShtOvRE+b2BUBadVn+6z/g=="],
@@ -1637,15 +1676,15 @@
"react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],
- "react-native-mmkv": ["react-native-mmkv@4.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-Osoy8as2ZLzO1TTsKxc4tX14Qk19qRVMWnS4ZVBwxie9Re5cjt7rqlpDkJczK3H/y3z70EQ6rmKI/cNMCLGAYQ=="],
+ "react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
- "react-native-nitro-modules": ["react-native-nitro-modules@0.31.5", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-h/IbVsK5IH7JkvseihAoz/o5dy6CafvGo7j4jTvAa+gnxZWFtXQZg8EDvu0en88LFAumKd/pcF20dzxMiNOmug=="],
+ "react-native-nitro-modules": ["react-native-nitro-modules@0.32.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-V+Vy76e4fxRxgVGu5Uh3cBPvuFQW8fM1OUKk1mqEA/JawjhX+hxHtBhpfuvNjV0BnV/uXCIg8/eK+rTpB6tqFg=="],
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],
"react-native-reanimated": ["react-native-reanimated@4.1.3", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", "react-native-worklets": ">=0.5.0" } }, "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg=="],
- "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-vNpCfPlFoOVKHd+oB7B0luoJswp+nyz0NdJD8+LCrf25JiNQXfM22RSJhLaksBHqk3fm8R4fKWPNcfy5w7wL1Q=="],
+ "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
@@ -1655,14 +1694,16 @@
"react-native-tab-view": ["react-native-tab-view@4.2.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="],
+ "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"],
+
"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-url-polyfill": ["react-native-url-polyfill@2.0.0", "", { "dependencies": { "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "react-native": "*" } }, "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA=="],
"react-native-uuid": ["react-native-uuid@2.0.3", "", {}, "sha512-f/YfIS2f5UB+gut7t/9BKGSCYbRA9/74A5R1MDp+FLYsuS+OSWoiM/D8Jko6OJB6Jcu3v6ONuddvZKHdIGpeiw=="],
- "react-native-video": ["react-native-video@6.16.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+G6tVVGbwFqNTyPivqb+PhQzWr5OudDQ1dgvBNyBRAgcS8rOcbwuS6oX+m8cxOsXHn1UT9ofQnjQEwkGOsvomg=="],
-
"react-native-volume-manager": ["react-native-volume-manager@2.0.8", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-aZM47/mYkdQ4CbXpKYO6Ajiczv7fxbQXZ9c0H8gRuQUaS3OCz/MZABer6o9aDWq0KMNsQ7q7GVFLRPnSSeeMmw=="],
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
@@ -1677,7 +1718,7 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
- "react-test-renderer": ["react-test-renderer@19.1.1", "", { "dependencies": { "react-is": "^19.1.1", "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-aGRXI+zcBTtg0diHofc7+Vy97nomBs9WHHFY1Csl3iV0x6xucjNYZZAkiVKGiNYUv23ecOex5jE67t8ZzqYObA=="],
+ "react-test-renderer": ["react-test-renderer@19.2.3", "", { "dependencies": { "react-is": "^19.2.3", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
@@ -1737,7 +1778,7 @@
"sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
- "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -1789,7 +1830,7 @@
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
- "sonner-native": ["sonner-native@0.21.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.10.1", "react-native-safe-area-context": ">=4.10.5", "react-native-screens": ">=3.31.1", "react-native-svg": ">=15.6.0" } }, "sha512-00RSmfVBd/XfQdRh7sqgFUjftx09HRgEMnZei4CVKcRKeqRcq9DXn5o1nJhz3aA4Cyf5k2+0kK4spdWtAtNqSA=="],
+ "sonner-native": ["sonner-native@0.21.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.10.1", "react-native-safe-area-context": ">=4.10.5", "react-native-screens": ">=3.31.1", "react-native-svg": ">=15.6.0" } }, "sha512-LnGPmfgzrNIwcc+FvcLJqx8aH1dEHePRzvNR8aIR4kl9spySRkXK160GmQIazjfm6mSMlPqZwRa5eycvrzg/eQ=="],
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
@@ -1867,6 +1908,8 @@
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
+ "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=="],
"tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="],
@@ -1907,7 +1950,7 @@
"unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="],
- "unimodules-app-loader": ["unimodules-app-loader@6.0.7", "", {}, "sha512-23iwxmh6/y54PRGJt/xjsOpPK8vlfusBisi3yaVSK22pxg5DmiL/+IHCtbb/crHC+gqdItcy1OoRsZQHfNSBaw=="],
+ "unimodules-app-loader": ["unimodules-app-loader@6.0.8", "", {}, "sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA=="],
"unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="],
@@ -1997,9 +2040,7 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
- "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
-
- "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
+ "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"@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=="],
@@ -2013,13 +2054,13 @@
"@expo/cli/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/cli/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "@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/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/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@expo/cli/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@expo/cli/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=="],
@@ -2029,53 +2070,45 @@
"@expo/config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "@expo/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/config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@expo/config/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+
+ "@expo/config/sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"@expo/config-plugins/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/config-plugins/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "@expo/config-plugins/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/config-plugins/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@expo/config-plugins/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
- "@expo/devcert/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
-
"@expo/env/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
"@expo/fingerprint/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/fingerprint/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "@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/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@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/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@expo/image-utils/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
- "@expo/mcp-tunnel/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
-
- "@expo/mcp-tunnel/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-
- "@expo/metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
-
- "@expo/metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
-
"@expo/metro-config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/metro-config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "@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/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/prebuild-config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@expo/prebuild-config/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
@@ -2109,16 +2142,30 @@
"@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+ "@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-core": ["metro-core@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.2" } }, "sha512-8DRb0O82Br0IW77cNgKMLYWUkx48lWxUkvNUxVISyMkcNwE/9ywf1MYQUE88HaKwSrqne6kFgCSA/UWZoUT0Iw=="],
+
"@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+ "@react-navigation/bottom-tabs/@react-navigation/elements": ["@react-navigation/elements@2.8.1", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-MLmuS5kPAeAFFOylw89WGjgEFBqGj/KBK6ZrFrAOqLnTqEzk52/SO1olb5GB00k6ZUCDZKJOp1BrLXslxE6TgQ=="],
+
"@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+ "@react-navigation/core/react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
+
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@react-navigation/material-top-tabs/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/@react-navigation/elements": ["@react-navigation/elements@2.8.1", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-MLmuS5kPAeAFFOylw89WGjgEFBqGj/KBK6ZrFrAOqLnTqEzk52/SO1olb5GB00k6ZUCDZKJOp1BrLXslxE6TgQ=="],
+
"@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.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
+
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
@@ -2127,8 +2174,6 @@
"ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
- "ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
-
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"babel-preset-expo/@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=="],
@@ -2137,6 +2182,8 @@
"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=="],
+
"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.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
@@ -2151,7 +2198,9 @@
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
- "expo-build-properties/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "expo-build-properties/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+
+ "expo-manifests/@expo/config": ["@expo/config@12.0.11", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.3", "@expo/config-types": "^54.0.9", "@expo/json-file": "^10.0.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "~3.35.1" } }, "sha512-bGKNCbHirwgFlcOJHXpsAStQvM0nU3cmiobK0o07UkTfcUxl9q9lOQQh2eoMGqpm6Vs1IcwBpYye6thC3Nri/w=="],
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
@@ -2201,20 +2250,10 @@
"metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
- "metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
-
- "metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
-
- "metro/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
-
"metro/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=="],
"metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
- "metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
-
- "metro-transform-worker/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
-
"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/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -2227,22 +2266,24 @@
"patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
- "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+ "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-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
- "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
-
"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=="],
"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-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
+ "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-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@@ -2287,6 +2328,8 @@
"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=="],
+
"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=="],
@@ -2295,6 +2338,8 @@
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
+ "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"@babel/highlight/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
@@ -2303,6 +2348,8 @@
"@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
+ "@expo/cli/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
+
"@expo/cli/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
"@expo/cli/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="],
@@ -2311,9 +2358,17 @@
"@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
- "@expo/metro/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
+ "@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
- "@expo/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
+ "@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/fingerprint/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
+
+ "@expo/metro-config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
@@ -2335,6 +2390,38 @@
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
+ "@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
+
+ "@react-native/community-cli-plugin/metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
+
+ "@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-rirY1QMFlA1uxH3ZiNauBninwTioOgwChnRdDcbB4tgRZ+bGX9DiXoh9QdpppiaVKXdJsII932OwWXGGV4+Nlw=="],
+
+ "@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="],
+
+ "@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-3EMG/GkGKYoTaf5RqguGLSWRqGTwO7NQ0qXKmNBjr0y6qD9s3VBXYlwB+MszGtmOKsqE9q3FPrE5Nd9Ipv7rZw=="],
+
+ "@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.83.2", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-cMSWnEqZrp/dzZIEd7DEDdk72PXz6w5NOKriJoDN9p1TDQ5nAYrY2lHi8d6mwbcGLoSlWmpPyny9HZYFfPWcGQ=="],
+
+ "@react-native/community-cli-plugin/metro/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/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/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
+
+ "@react-native/community-cli-plugin/metro/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
+
+ "@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-5WlW25WKPkiJk2yA9d8bMuZrgW7vfA4f4MBb9ZeHbTB3eIAoNN8vS8NENgG/X/90vpTB06X66OBvxhT3nHwP6A=="],
+
+ "@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-minify-terser": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "nullthrows": "^1.1.1" } }, "sha512-G5DsIg+cMZ2KNfrdLnWMvtppb3+Rp1GMyj7Bvd9GgYc/8gRmvq1XVEF9XuO87Shhb03kFhGqMTgZerz3hZ1v4Q=="],
+
+ "@react-native/community-cli-plugin/metro/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-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="],
+
+ "@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-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-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
@@ -2357,16 +2444,32 @@
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
- "ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
-
"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=="],
+
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
+ "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "expo-manifests/@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
+
+ "expo-manifests/@expo/config/@expo/config-plugins": ["@expo/config-plugins@54.0.3", "", { "dependencies": { "@expo/config-types": "^54.0.9", "@expo/json-file": "~10.0.7", "@expo/plist": "^0.4.7", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-tBIUZIxLQfCu5jmqTO+UOeeDUGIB0BbK6xTMkPRObAXRQeTLPPfokZRCo818d2owd+Bcmq1wBaDz0VY3g+glfw=="],
+
+ "expo-manifests/@expo/config/@expo/config-types": ["@expo/config-types@54.0.9", "", {}, "sha512-Llf4jwcrAnrxgE5WCdAOxtMf8FGwS4Sk0SSgI0NnIaSyCnmOCAm80GPFvsK778Oj19Ub4tSyzdqufPyeQPksWw=="],
+
+ "expo-manifests/@expo/config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
+
+ "expo-manifests/@expo/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-manifests/@expo/config/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+
+ "expo-manifests/@expo/config/sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
+
"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=="],
@@ -2387,14 +2490,8 @@
"metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
- "metro-transform-worker/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
-
- "metro-transform-worker/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
-
"metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
- "metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
-
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
@@ -2411,10 +2508,14 @@
"serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
+ "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=="],
"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=="],
+ "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=="],
@@ -2433,6 +2534,8 @@
"@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
+ "@expo/cli/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
"@expo/package-manager/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -2445,6 +2548,12 @@
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+ "@react-native/community-cli-plugin/metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
+
+ "@react-native/community-cli-plugin/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
+
+ "@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw=="],
+
"@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -2463,8 +2572,18 @@
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
+ "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+ "cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "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=="],
+
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -2477,6 +2596,10 @@
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "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=="],
+
"@babel/highlight/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-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
@@ -2485,6 +2608,8 @@
"@expo/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="],
+ "@expo/cli/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
"@expo/package-manager/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"@expo/package-manager/ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
@@ -2495,6 +2620,10 @@
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
+ "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": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
"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=="],
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
@@ -2505,6 +2634,10 @@
"@expo/package-manager/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
+ "logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
"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/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
}
}
diff --git a/components/AccountsSheet.tsx b/components/AccountsSheet.tsx
new file mode 100644
index 00000000..9f089455
--- /dev/null
+++ b/components/AccountsSheet.tsx
@@ -0,0 +1,223 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, Platform, TouchableOpacity, View } from "react-native";
+import { Swipeable } from "react-native-gesture-handler";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Colors } from "@/constants/Colors";
+import {
+ deleteAccountCredential,
+ type SavedServer,
+ type SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+
+interface AccountsSheetProps {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ server: SavedServer | null;
+ onAccountSelect: (account: SavedServerAccount) => void;
+ onAddAccount: () => void;
+ onAccountDeleted?: () => void;
+}
+
+export const AccountsSheet: React.FC = ({
+ open,
+ setOpen,
+ server,
+ onAccountSelect,
+ onAddAccount,
+ onAccountDeleted,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["50%"]),
+ [isAndroid],
+ );
+
+ useEffect(() => {
+ if (open) {
+ bottomSheetModalRef.current?.present();
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleDeleteAccount = async (account: SavedServerAccount) => {
+ if (!server) return;
+
+ Alert.alert(
+ t("server.remove_saved_login"),
+ t("server.remove_account_description", { username: account.username }),
+ [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.remove"),
+ style: "destructive",
+ onPress: async () => {
+ await deleteAccountCredential(server.address, account.userId);
+ onAccountDeleted?.();
+ },
+ },
+ ],
+ );
+ };
+
+ const getSecurityIcon = (
+ securityType: SavedServerAccount["securityType"],
+ ): keyof typeof Ionicons.glyphMap => {
+ switch (securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
+ const renderRightActions = (account: SavedServerAccount) => (
+ handleDeleteAccount(account)}
+ className='bg-red-600 justify-center items-center px-5'
+ >
+
+
+ );
+
+ if (!server) return null;
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("server.select_account")}
+
+
+ {server.name || server.address}
+
+
+
+ {/* Account List */}
+
+ {server.accounts.map((account, index) => (
+ renderRightActions(account)}
+ overshootRight={false}
+ >
+ {
+ setOpen(false);
+ onAccountSelect(account);
+ }}
+ className={`flex-row items-center p-4 bg-neutral-800 ${
+ index < server.accounts.length - 1
+ ? "border-b border-neutral-700"
+ : ""
+ }`}
+ >
+ {/* Avatar */}
+
+
+
+
+ {/* Account Info */}
+
+
+ {account.username}
+
+
+ {account.securityType === "none"
+ ? t("save_account.no_protection")
+ : account.securityType === "pin"
+ ? t("save_account.pin_code")
+ : t("save_account.password")}
+
+
+
+ {/* Security Icon */}
+
+
+
+ ))}
+
+
+ {/* Hint */}
+
+ {t("server.swipe_to_remove")}
+
+
+ {/* Add Account Button */}
+
+
+
+
+ );
+};
diff --git a/components/AddToFavorites.tsx b/components/AddToFavorites.tsx
index c69a83df..35221f1a 100644
--- a/components/AddToFavorites.tsx
+++ b/components/AddToFavorites.tsx
@@ -16,6 +16,7 @@ export const AddToFavorites: FC = ({ item, ...props }) => {
diff --git a/components/AddToWatchlist.tsx b/components/AddToWatchlist.tsx
new file mode 100644
index 00000000..0e1d6a37
--- /dev/null
+++ b/components/AddToWatchlist.tsx
@@ -0,0 +1,43 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import type { FC } from "react";
+import { useCallback, useRef } from "react";
+import { View, type ViewProps } from "react-native";
+import { RoundButton } from "@/components/RoundButton";
+import {
+ WatchlistSheet,
+ type WatchlistSheetRef,
+} from "@/components/watchlists/WatchlistSheet";
+import {
+ useItemInWatchlists,
+ useStreamystatsEnabled,
+} from "@/hooks/useWatchlists";
+
+interface Props extends ViewProps {
+ item: BaseItemDto;
+}
+
+export const AddToWatchlist: FC = ({ item, ...props }) => {
+ const streamystatsEnabled = useStreamystatsEnabled();
+ const sheetRef = useRef(null);
+
+ const { data: watchlistsContainingItem } = useItemInWatchlists(item.Id);
+ const isInAnyWatchlist = (watchlistsContainingItem?.length ?? 0) > 0;
+
+ const handlePress = useCallback(() => {
+ sheetRef.current?.open(item);
+ }, [item]);
+
+ // Don't render if Streamystats is not enabled
+ if (!streamystatsEnabled) return null;
+
+ return (
+
+
+
+
+ );
+};
diff --git a/components/Badge.tsx b/components/Badge.tsx
index aff8cb83..b33fff2b 100644
--- a/components/Badge.tsx
+++ b/components/Badge.tsx
@@ -1,4 +1,5 @@
-import { View, type ViewProps } from "react-native";
+import { Platform, StyleSheet, View, type ViewProps } from "react-native";
+import { GlassEffectView } from "react-native-glass-effect-view";
import { Text } from "./common/Text";
interface Props extends ViewProps {
@@ -13,6 +14,30 @@ export const Badge: React.FC = ({
variant = "purple",
...props
}) => {
+ const content = (
+
+ {iconLeft && {iconLeft}}
+
+ {text}
+
+
+ );
+
+ if (Platform.OS === "ios") {
+ return (
+
+
+ {content}
+
+
+ );
+ }
+
return (
= ({
);
};
+
+const styles = StyleSheet.create({
+ container: {
+ overflow: "hidden",
+ alignSelf: "flex-start",
+ flexShrink: 1,
+ flexGrow: 0,
+ },
+ content: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 50,
+ backgroundColor: "transparent",
+ },
+ iconLeft: {
+ marginRight: 4,
+ },
+});
diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx
index e009a12d..dbc36f9c 100644
--- a/components/Chromecast.tsx
+++ b/components/Chromecast.tsx
@@ -1,6 +1,7 @@
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react";
-import { Platform, TouchableOpacity } from "react-native";
+import { Platform } from "react-native";
+import { Pressable } from "react-native-gesture-handler";
import GoogleCast, {
CastButton,
CastContext,
@@ -44,7 +45,7 @@ export function Chromecast({
if (Platform.OS === "ios") {
return (
- {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
@@ -54,7 +55,7 @@ export function Chromecast({
>
-
+
);
}
diff --git a/components/ContextMenu.tv.ts b/components/ContextMenu.tv.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 2f2268b6..e50b4efc 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -9,13 +9,14 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-import { type Href, router } from "expo-router";
+import { type Href } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
import { toast } from "sonner-native";
+import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -62,6 +63,7 @@ export const DownloadItems: React.FC = ({
const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom);
const { settings } = useSettings();
+ const router = useRouter();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
@@ -109,7 +111,7 @@ export const DownloadItems: React.FC = ({
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
- mediaSource: defaultMediaSource,
+ mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
@@ -170,9 +172,11 @@ export const DownloadItems: React.FC = ({
firstItem.Type !== "Episode"
? "/downloads"
: ({
- pathname: `/downloads/${firstItem.SeriesId}`,
+ pathname: "/series/[id]",
params: {
- episodeSeasonIndex: firstItem.ParentIndexNumber,
+ id: firstItem.SeriesId!,
+ seasonIndex: firstItem.ParentIndexNumber?.toString(),
+ offline: "true",
},
} as Href),
);
@@ -209,6 +213,7 @@ export const DownloadItems: React.FC = ({
subtitleStreamIndex: subtitleIndex ?? -1,
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
deviceId: api.deviceInfo.id,
+ audioMode: settings?.audioTranscodeMode,
});
return {
@@ -236,11 +241,23 @@ export const DownloadItems: React.FC = ({
);
continue;
}
+ // Get the audio/subtitle indices that were used for this download
+ const downloadAudioIndex =
+ itemsNotDownloaded.length > 1
+ ? getDefaultPlaySettings(item, settings!).audioIndex
+ : selectedOptions?.audioIndex;
+ const downloadSubtitleIndex =
+ itemsNotDownloaded.length > 1
+ ? getDefaultPlaySettings(item, settings!).subtitleIndex
+ : selectedOptions?.subtitleIndex;
+
await startBackgroundDownload(
url,
item,
mediaSource,
selectedOptions?.bitrate || defaultBitrate,
+ downloadAudioIndex,
+ downloadSubtitleIndex,
);
}
},
diff --git a/components/ExampleGlobalModalUsage.tsx b/components/ExampleGlobalModalUsage.tsx
deleted file mode 100644
index ccebb823..00000000
--- a/components/ExampleGlobalModalUsage.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-/**
- * Example Usage of Global Modal
- *
- * This file demonstrates how to use the global modal system from anywhere in your app.
- * You can delete this file after understanding how it works.
- */
-
-import { Ionicons } from "@expo/vector-icons";
-import { TouchableOpacity, View } from "react-native";
-import { Text } from "@/components/common/Text";
-import { useGlobalModal } from "@/providers/GlobalModalProvider";
-
-/**
- * Example 1: Simple Content Modal
- */
-export const SimpleModalExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal(
-
- Simple Modal
-
- This is a simple modal with just some text content.
-
-
- Swipe down or tap outside to close.
-
- ,
- );
- };
-
- return (
-
- Open Simple Modal
-
- );
-};
-
-/**
- * Example 2: Modal with Custom Snap Points
- */
-export const CustomSnapPointsExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal(
-
-
- Custom Snap Points
-
-
- This modal has custom snap points (25%, 50%, 90%).
-
-
-
- Try dragging the modal to different heights!
-
-
- ,
- {
- snapPoints: ["25%", "50%", "90%"],
- enableDynamicSizing: false,
- },
- );
- };
-
- return (
-
- Custom Snap Points
-
- );
-};
-
-/**
- * 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 (
-
- Settings
-
- {settings.map((setting, index) => (
-
-
-
- {setting.title}
-
-
-
-
-
- ))}
-
-
- Close
-
-
- );
-};
-
-export const ComplexModalExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal();
- };
-
- return (
-
- Complex Component
-
- );
-};
-
-/**
- * Example 4: Modal Triggered from Function (e.g., API response)
- */
-export const useShowSuccessModal = () => {
- const { showModal } = useGlobalModal();
-
- return (message: string) => {
- showModal(
-
-
-
-
- Success!
- {message}
- ,
- );
- };
-};
-
-/**
- * Main Demo Component
- */
-export const GlobalModalDemo = () => {
- const showSuccess = useShowSuccessModal();
-
- return (
-
-
- Global Modal Examples
-
-
-
-
-
-
- showSuccess("Operation completed successfully!")}
- className='bg-orange-600 px-4 py-2 rounded-lg'
- >
- Show Success Modal
-
-
- );
-};
diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx
index 6708269d..030d554c 100644
--- a/components/GenreTags.tsx
+++ b/components/GenreTags.tsx
@@ -1,11 +1,14 @@
// GenreTags.tsx
import type React from "react";
import {
+ Platform,
type StyleProp,
+ StyleSheet,
type TextStyle,
View,
type ViewProps,
} from "react-native";
+import { GlassEffectView } from "react-native-glass-effect-view";
import { Text } from "./common/Text";
interface TagProps {
@@ -20,6 +23,23 @@ export const Tag: React.FC<
textStyle?: StyleProp;
} & ViewProps
> = ({ text, textClass, textStyle, ...props }) => {
+ if (Platform.OS === "ios") {
+ return (
+
+
+
+ {text}
+
+
+
+ );
+ }
+
return (
@@ -29,6 +49,16 @@ export const Tag: React.FC<
);
};
+const styles = StyleSheet.create({
+ container: {
+ overflow: "hidden",
+ borderRadius: 50,
+ },
+ glass: {
+ borderRadius: 50,
+ },
+});
+
export const Tags: React.FC<
TagProps & { tagProps?: ViewProps } & ViewProps
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
diff --git a/components/GlobalModal.tsx b/components/GlobalModal.tsx
index 361321d3..107f78e4 100644
--- a/components/GlobalModal.tsx
+++ b/components/GlobalModal.tsx
@@ -3,7 +3,7 @@ import {
type BottomSheetBackdropProps,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
@@ -16,7 +16,13 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
* after BottomSheetModalProvider.
*/
export const GlobalModal = () => {
- const { hideModal, modalState, modalRef } = useGlobalModal();
+ const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
+
+ useEffect(() => {
+ if (isVisible && modalState.content) {
+ modalRef.current?.present();
+ }
+ }, [isVisible, modalState.content, modalRef]);
const handleSheetChanges = useCallback(
(index: number) => {
diff --git a/components/IntroSheet.tsx b/components/IntroSheet.tsx
new file mode 100644
index 00000000..7317b7e8
--- /dev/null
+++ b/components/IntroSheet.tsx
@@ -0,0 +1,203 @@
+import { Feather, Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetScrollView,
+} from "@gorhom/bottom-sheet";
+import { Image } from "expo-image";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { Linking, Platform, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { storage } from "@/utils/mmkv";
+
+export interface IntroSheetRef {
+ present: () => void;
+ dismiss: () => void;
+}
+
+export const IntroSheet = forwardRef((_, ref) => {
+ const bottomSheetRef = useRef(null);
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+
+ useImperativeHandle(ref, () => ({
+ present: () => {
+ storage.set("hasShownIntro", true);
+ bottomSheetRef.current?.present();
+ },
+ dismiss: () => {
+ bottomSheetRef.current?.dismiss();
+ },
+ }));
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleDismiss = useCallback(() => {
+ bottomSheetRef.current?.dismiss();
+ }, []);
+
+ const handleGoToSettings = useCallback(() => {
+ bottomSheetRef.current?.dismiss();
+ router.push("/settings");
+ }, []);
+
+ return (
+
+
+
+
+
+ {t("home.intro.welcome_to_streamyfin")}
+
+
+ {t("home.intro.a_free_and_open_source_client_for_jellyfin")}
+
+
+
+
+
+ {t("home.intro.features_title")}
+
+
+ {t("home.intro.features_description")}
+
+
+
+
+ Seerr
+
+ {t("home.intro.seerr_feature_description")}
+
+
+
+ {!Platform.isTV && (
+ <>
+
+
+
+
+
+
+ {t("home.intro.downloads_feature_title")}
+
+
+ {t("home.intro.downloads_feature_description")}
+
+
+
+
+
+
+
+
+ Chromecast
+
+ {t("home.intro.chromecast_feature_description")}
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ {t("home.intro.centralized_settings_plugin_title")}
+
+
+
+ {t(
+ "home.intro.centralized_settings_plugin_description",
+ )}{" "}
+
+ {
+ Linking.openURL(
+ "https://github.com/streamyfin/jellyfin-plugin-streamyfin",
+ );
+ }}
+ >
+
+ {t("home.intro.read_more")}
+
+
+
+
+
+
+
+
+
+
+
+ {t("home.intro.go_to_settings_button")}
+
+
+
+
+
+
+
+
+ );
+});
+
+IntroSheet.displayName = "IntroSheet";
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index 6b8f1bba..1b2cbbac 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -11,6 +11,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { DownloadSingleItem } from "@/components/DownloadItem";
+import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
import { MediaSourceButton } from "@/components/MediaSourceButton";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -18,7 +19,6 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems";
-import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
@@ -26,12 +26,13 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites";
+import { AddToWatchlist } from "./AddToWatchlist";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
-import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
@@ -45,13 +46,13 @@ export type SelectedOptions = {
interface ItemContentProps {
item: BaseItemDto;
- isOffline: boolean;
itemWithSources?: BaseItemDto | null;
}
export const ItemContent: React.FC = React.memo(
- ({ item, isOffline, itemWithSources }) => {
+ ({ item, itemWithSources }) => {
const [api] = useAtom(apiAtom);
+ const isOffline = useOfflineMode();
const { settings } = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
@@ -67,18 +68,23 @@ export const ItemContent: React.FC = React.memo(
SelectedOptions | undefined
>(undefined);
+ // Use itemWithSources for play settings since it has MediaSources data
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
- } = useDefaultPlaySettings(item!, settings);
+ } = useDefaultPlaySettings(itemWithSources ?? item, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
+ const onLogoLoad = React.useCallback(() => {
+ setLoadingLogo(false);
+ }, []);
+
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
@@ -87,7 +93,7 @@ export const ItemContent: React.FC = React.memo(
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
- mediaSource: defaultMediaSource,
+ mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
@@ -111,12 +117,17 @@ export const ItemContent: React.FC = React.memo(
{!Platform.isTV && (
)}
- {user?.Policy?.IsAdministrator && (
-
- )}
+ {user?.Policy?.IsAdministrator &&
+ !settings.hideRemoteSessionButton && (
+
+ )}
+ {settings.streamyStatsServerUrl &&
+ !settings.hideWatchlistsTab && (
+
+ )}
)}
@@ -128,19 +139,32 @@ export const ItemContent: React.FC = React.memo(
{!Platform.isTV && (
)}
- {user?.Policy?.IsAdministrator && (
-
- )}
+ {user?.Policy?.IsAdministrator &&
+ !settings.hideRemoteSessionButton && (
+
+ )}
+ {settings.streamyStatsServerUrl &&
+ !settings.hideWatchlistsTab && (
+
+ )}
)}
)),
});
}
- }, [item, navigation, user, itemWithSources]);
+ }, [
+ item,
+ navigation,
+ user,
+ itemWithSources,
+ settings.hideRemoteSessionButton,
+ settings.streamyStatsServerUrl,
+ settings.hideWatchlistsTab,
+ ]);
useEffect(() => {
if (item) {
@@ -162,7 +186,7 @@ export const ItemContent: React.FC = React.memo(
}}
>
@@ -189,8 +213,8 @@ export const ItemContent: React.FC = React.memo(
width: "100%",
}}
contentFit='contain'
- onLoad={() => setLoadingLogo(false)}
- onError={() => setLoadingLogo(false)}
+ onLoad={onLogoLoad}
+ onError={onLogoLoad}
/>
) : (
@@ -205,7 +229,6 @@ export const ItemContent: React.FC = React.memo(
@@ -220,11 +243,7 @@ export const ItemContent: React.FC = React.memo(
{item.Type === "Episode" && (
-
+
)}
{!isOffline &&
@@ -238,25 +257,10 @@ export const ItemContent: React.FC = React.memo(
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && !isOffline && (
-
+
)}
- {!isOffline && (
-
- )}
-
- {item.People && item.People.length > 0 && !isOffline && (
-
- {item.People.slice(0, 3).map((person, idx) => (
-
- ))}
-
- )}
+
{!isOffline && }
>
diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx
index c0d5e54d..49614a2c 100644
--- a/components/ItemTechnicalDetails.tsx
+++ b/components/ItemTechnicalDetails.tsx
@@ -183,6 +183,12 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
if (!source || !videoStream) return null;
+ // Dolby Vision video check
+ const isDolbyVision =
+ videoStream.VideoRangeType === "DOVI" ||
+ videoStream.DvVersionMajor != null ||
+ videoStream.DvVersionMinor != null;
+
return (
{
iconLeft={}
text={`${videoStream.Width}x${videoStream.Height}`}
/>
+ {isDolbyVision && (
+
+ }
+ text={"DV"}
+ />
+ )}
= ({
actorId,
+ actorName,
currentItem,
...props
}) => {
@@ -27,19 +32,6 @@ export const MoreMoviesWithActor: React.FC = ({
const [user] = useAtom(userAtom);
const { t } = useTranslation();
- const { data: actor } = useQuery({
- queryKey: ["actor", actorId],
- queryFn: async () => {
- if (!api || !user?.Id) return null;
- return await getUserItemData({
- api,
- userId: user.Id,
- itemId: actorId,
- });
- },
- enabled: !!api && !!user?.Id && !!actorId,
- });
-
const { data: items, isLoading } = useQuery({
queryKey: ["actor", "movies", actorId, currentItem.Id],
queryFn: async () => {
@@ -72,29 +64,34 @@ export const MoreMoviesWithActor: React.FC = ({
enabled: !!api && !!user?.Id && !!actorId,
});
+ const renderItem = useCallback(
+ (item: BaseItemDto, idx: number) => (
+
+
+
+
+
+
+ ),
+ [],
+ );
+
if (items?.length === 0) return null;
return (
- {t("item_card.more_with", { name: actor?.Name })}
+ {t("item_card.more_with", { name: actorName ?? "" })}
(
-
-
-
-
-
-
- )}
+ height={POSTER_CAROUSEL_HEIGHT}
+ renderItem={renderItem}
/>
);
diff --git a/components/PINEntryModal.tsx b/components/PINEntryModal.tsx
new file mode 100644
index 00000000..f450f5eb
--- /dev/null
+++ b/components/PINEntryModal.tsx
@@ -0,0 +1,231 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Alert,
+ Animated,
+ Keyboard,
+ Platform,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useHaptic } from "@/hooks/useHaptic";
+import { verifyAccountPIN } from "@/utils/secureCredentials";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+import { PinInput } from "./inputs/PinInput";
+
+interface PINEntryModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ onForgotPIN?: () => void;
+ serverUrl: string;
+ userId: string;
+ username: string;
+}
+
+export const PINEntryModal: React.FC = ({
+ visible,
+ onClose,
+ onSuccess,
+ onForgotPIN,
+ serverUrl,
+ userId,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+ const [pinCode, setPinCode] = useState("");
+ const [error, setError] = useState(null);
+ const [isVerifying, setIsVerifying] = useState(false);
+ const shakeAnimation = useRef(new Animated.Value(0)).current;
+ const errorHaptic = useHaptic("error");
+ const successHaptic = useHaptic("success");
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["50%"]),
+ [isAndroid],
+ );
+
+ useEffect(() => {
+ if (visible) {
+ bottomSheetModalRef.current?.present();
+ setPinCode("");
+ setError(null);
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [visible]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setPinCode("");
+ setError(null);
+ onClose();
+ }
+ },
+ [onClose],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const shake = () => {
+ Animated.sequence([
+ Animated.timing(shakeAnimation, {
+ toValue: 10,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shakeAnimation, {
+ toValue: -10,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shakeAnimation, {
+ toValue: 10,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shakeAnimation, {
+ toValue: 0,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ };
+
+ const handlePinChange = async (value: string) => {
+ setPinCode(value);
+ setError(null);
+
+ // Auto-verify when 4 digits entered
+ if (value.length === 4) {
+ setIsVerifying(true);
+ try {
+ const isValid = await verifyAccountPIN(serverUrl, userId, value);
+ if (isValid) {
+ Keyboard.dismiss();
+ successHaptic();
+ onSuccess();
+ setPinCode("");
+ } else {
+ errorHaptic();
+ setError(t("pin.invalid_pin"));
+ shake();
+ setPinCode("");
+ }
+ } catch {
+ errorHaptic();
+ setError(t("pin.invalid_pin"));
+ shake();
+ setPinCode("");
+ } finally {
+ setIsVerifying(false);
+ }
+ }
+ };
+
+ const handleForgotPIN = () => {
+ Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.continue"),
+ style: "destructive",
+ onPress: () => {
+ onClose();
+ onForgotPIN?.();
+ },
+ },
+ ]);
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("pin.enter_pin")}
+
+
+ {t("pin.enter_pin_for", { username })}
+
+
+
+ {/* PIN Input */}
+
+
+ {error && (
+ {error}
+ )}
+ {isVerifying && (
+
+ {t("common.verifying") || "Verifying..."}
+
+ )}
+
+
+ {/* Forgot PIN */}
+
+
+ {t("pin.forgot_pin")}
+
+
+
+ {/* Cancel Button */}
+
+
+
+
+ );
+};
diff --git a/components/PasswordEntryModal.tsx b/components/PasswordEntryModal.tsx
new file mode 100644
index 00000000..63b4efe6
--- /dev/null
+++ b/components/PasswordEntryModal.tsx
@@ -0,0 +1,185 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetTextInput,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ActivityIndicator, Platform, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useHaptic } from "@/hooks/useHaptic";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+
+interface PasswordEntryModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSubmit: (password: string) => Promise;
+ username: string;
+}
+
+export const PasswordEntryModal: React.FC = ({
+ visible,
+ onClose,
+ onSubmit,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const errorHaptic = useHaptic("error");
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["50%"]),
+ [isAndroid],
+ );
+
+ useEffect(() => {
+ if (visible) {
+ bottomSheetModalRef.current?.present();
+ setPassword("");
+ setError(null);
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [visible]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setPassword("");
+ setError(null);
+ onClose();
+ }
+ },
+ [onClose],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleSubmit = async () => {
+ if (!password) {
+ setError(t("password.enter_password"));
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ await onSubmit(password);
+ setPassword("");
+ } catch {
+ errorHaptic();
+ setError(t("password.invalid_password"));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("password.enter_password")}
+
+
+ {t("password.enter_password_for", { username })}
+
+
+
+ {/* Password Input */}
+
+
+ {t("login.password")}
+
+ {
+ setPassword(text);
+ setError(null);
+ }}
+ placeholder={t("login.password")}
+ placeholderTextColor='#6B7280'
+ secureTextEntry
+ autoFocus
+ autoCapitalize='none'
+ autoCorrect={false}
+ style={{
+ backgroundColor: "#1F2937",
+ borderRadius: 8,
+ padding: 12,
+ color: "white",
+ fontSize: 16,
+ }}
+ onSubmitEditing={handleSubmit}
+ returnKeyType='done'
+ />
+ {error && {error}}
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx
index 43ecb6c9..b33d2b2c 100644
--- a/components/PlatformDropdown.tsx
+++ b/components/PlatformDropdown.tsx
@@ -54,9 +54,7 @@ const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
);
@@ -73,9 +71,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
{option.label}
{isToggle ? (
@@ -219,11 +215,7 @@ const PlatformDropdownComponent = ({
return (
-
-
- {trigger || }
-
-
+ {trigger}
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index 6dee64c2..1c3fd46f 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -1,14 +1,14 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
-import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
+import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
+ MediaStreamType,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -23,11 +23,13 @@ import Animated, {
useSharedValue,
withTiming,
} 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";
@@ -43,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps {
item: BaseItemDto;
selectedOptions: SelectedOptions;
- isOffline?: boolean;
colors?: ThemeColors;
}
@@ -53,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC = ({
item,
selectedOptions,
- isOffline,
colors,
}: Props) => {
+ const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
@@ -185,11 +186,23 @@ export const PlayButton: React.FC = ({
return;
}
+ // 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"
? {
@@ -241,7 +254,7 @@ export const PlayButton: React.FC = ({
],
},
},
- startTime: 0,
+ startTime: startTimeSeconds,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
@@ -280,7 +293,6 @@ export const PlayButton: React.FC = ({
]);
const onPress = useCallback(async () => {
- console.log("onPress");
if (!item) return;
lightHapticFeedback();
@@ -288,6 +300,19 @@ export const PlayButton: React.FC = ({
// 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
@@ -474,52 +499,6 @@ export const PlayButton: React.FC = ({
),
}));
- // if (Platform.OS === "ios")
- // return (
- //
- //
- //
- // );
-
return (
= ({
)}
- {!client && settings?.openInVLC && (
-
-
-
- )}
diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx
index 8e3b9811..f79a3174 100644
--- a/components/PlayButton.tv.tsx
+++ b/components/PlayButton.tv.tsx
@@ -1,6 +1,5 @@
-import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
+import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { TouchableOpacity, View } from "react-native";
@@ -14,10 +13,10 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
+import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
-import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
@@ -50,7 +49,6 @@ export const PlayButton: React.FC = ({
const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
- const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
@@ -61,7 +59,6 @@ export const PlayButton: React.FC = ({
);
const onPress = () => {
- console.log("onpress");
if (!item) return;
lightHapticFeedback();
@@ -207,15 +204,6 @@ export const PlayButton: React.FC = ({
- {settings?.openInVLC && (
-
-
-
- )}
diff --git a/components/PlaybackSpeedSelector.tsx b/components/PlaybackSpeedSelector.tsx
new file mode 100644
index 00000000..70b69ba4
--- /dev/null
+++ b/components/PlaybackSpeedSelector.tsx
@@ -0,0 +1,180 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, View } from "react-native";
+import { useSettings } from "@/utils/atoms/settings";
+import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
+import { PlaybackSpeedScope } from "./video-player/controls/utils/playback-speed-settings";
+
+export const PLAYBACK_SPEEDS = [
+ { label: "0.25x", value: 0.25 },
+ { label: "0.5x", value: 0.5 },
+ { label: "0.75x", value: 0.75 },
+ { label: "1x", value: 1.0 },
+ { label: "1.25x", value: 1.25 },
+ { label: "1.5x", value: 1.5 },
+ { label: "1.75x", value: 1.75 },
+ { label: "2x", value: 2.0 },
+ { label: "2.25x", value: 2.25 },
+ { label: "2.5x", value: 2.5 },
+ { label: "2.75x", value: 2.75 },
+ { label: "3x", value: 3.0 },
+];
+
+interface Props extends React.ComponentProps {
+ onChange: (value: number, scope: PlaybackSpeedScope) => void;
+ selected: number;
+ item?: BaseItemDto;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}
+
+export const PlaybackSpeedSelector: React.FC = ({
+ onChange,
+ selected,
+ item,
+ open: controlledOpen,
+ onOpenChange,
+ ...props
+}) => {
+ const isTv = Platform.isTV;
+ const { t } = useTranslation();
+ const { settings } = useSettings();
+ const [internalOpen, setInternalOpen] = useState(false);
+
+ // Determine initial scope based on existing settings
+ const initialScope = useMemo(() => {
+ if (!item || !settings) return PlaybackSpeedScope.All;
+
+ const itemId = item?.Id;
+ if (!itemId) return PlaybackSpeedScope.All;
+
+ // Check for media-specific speed preference
+ if (settings?.playbackSpeedPerMedia?.[itemId] !== undefined) {
+ return PlaybackSpeedScope.Media;
+ }
+
+ // Check for show-specific speed preference (only for episodes)
+ const seriesId = item?.SeriesId;
+ const perShowSettings = settings?.playbackSpeedPerShow;
+ if (
+ seriesId &&
+ perShowSettings &&
+ perShowSettings[seriesId] !== undefined
+ ) {
+ return PlaybackSpeedScope.Show;
+ }
+
+ // If no custom setting exists, check default playback speed
+ // Show "All" if speed is not 1x, otherwise show "Media"
+ return (settings?.defaultPlaybackSpeed ?? 1.0) !== 1.0
+ ? PlaybackSpeedScope.All
+ : PlaybackSpeedScope.Media;
+ }, [item?.Id, item?.SeriesId, settings]);
+
+ const [selectedScope, setSelectedScope] =
+ useState(initialScope);
+
+ // Update selectedScope when initialScope changes
+ useEffect(() => {
+ setSelectedScope(initialScope);
+ }, [initialScope]);
+
+ const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
+ const setOpen = onOpenChange || setInternalOpen;
+
+ const scopeLabels = useMemo>(() => {
+ const labels: Record = {
+ [PlaybackSpeedScope.Media]: t("playback_speed.scope.media"),
+ };
+
+ if (item?.SeriesId) {
+ labels[PlaybackSpeedScope.Show] = t("playback_speed.scope.show");
+ }
+
+ labels[PlaybackSpeedScope.All] = t("playback_speed.scope.all");
+
+ return labels as Record;
+ }, [item?.SeriesId, t]);
+
+ const availableScopes = useMemo(() => {
+ const scopes = [PlaybackSpeedScope.Media];
+ if (item?.SeriesId) {
+ scopes.push(PlaybackSpeedScope.Show);
+ }
+ scopes.push(PlaybackSpeedScope.All);
+ return scopes;
+ }, [item?.SeriesId]);
+
+ const handleSpeedSelect = useCallback(
+ (speed: number) => {
+ onChange(speed, selectedScope);
+ setOpen(false);
+ },
+ [onChange, selectedScope, setOpen],
+ );
+
+ const optionGroups = useMemo(() => {
+ const groups: OptionGroup[] = [];
+
+ // Scope selection group
+ groups.push({
+ title: t("playback_speed.apply_to"),
+ options: availableScopes.map((scope) => ({
+ type: "radio" as const,
+ label: scopeLabels[scope],
+ value: scope,
+ selected: selectedScope === scope,
+ onPress: () => setSelectedScope(scope),
+ })),
+ });
+
+ // Speed selection group
+ groups.push({
+ title: t("playback_speed.speed"),
+ options: PLAYBACK_SPEEDS.map((speed) => ({
+ type: "radio" as const,
+ label: speed.label,
+ value: speed.value,
+ selected: selected === speed.value,
+ onPress: () => handleSpeedSelect(speed.value),
+ })),
+ });
+
+ return groups;
+ }, [
+ t,
+ availableScopes,
+ scopeLabels,
+ selectedScope,
+ selected,
+ handleSpeedSelect,
+ ]);
+
+ const trigger = useMemo(
+ () => (
+
+
+
+ ),
+ [],
+ );
+
+ if (isTv) return null;
+
+ return (
+
+
+
+ );
+};
diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx
index 0023b014..dd2198cd 100644
--- a/components/PlayedStatus.tsx
+++ b/components/PlayedStatus.tsx
@@ -1,12 +1,12 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
+import { useCallback } from "react";
import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
items: BaseItemDto[];
- isOffline?: boolean;
size?: "default" | "large";
}
@@ -14,14 +14,16 @@ export const PlayedStatus: React.FC = ({ items, ...props }) => {
const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items);
+ const handlePress = useCallback(() => {
+ void toggle(!allPlayed);
+ }, [allPlayed, toggle]);
+
return (
{
- await toggle(!allPlayed);
- }}
+ onPress={handlePress}
size={props.size}
/>
diff --git a/components/PreviousServersList.tsx b/components/PreviousServersList.tsx
index ffa310d3..008e1be2 100644
--- a/components/PreviousServersList.tsx
+++ b/components/PreviousServersList.tsx
@@ -1,42 +1,266 @@
+import { Ionicons } from "@expo/vector-icons";
import type React from "react";
-import { useMemo } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { View } from "react-native";
+import { ActivityIndicator, Alert, TouchableOpacity, View } from "react-native";
+import { Swipeable } from "react-native-gesture-handler";
import { useMMKVString } from "react-native-mmkv";
+import { Colors } from "@/constants/Colors";
+import {
+ deleteAccountCredential,
+ getPreviousServers,
+ removeServerFromList,
+ type SavedServer,
+ type SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { AccountsSheet } from "./AccountsSheet";
+import { Text } from "./common/Text";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
-
-interface Server {
- address: string;
-}
+import { PasswordEntryModal } from "./PasswordEntryModal";
+import { PINEntryModal } from "./PINEntryModal";
interface PreviousServersListProps {
- onServerSelect: (server: Server) => void;
+ onServerSelect: (server: SavedServer) => void;
+ onQuickLogin?: (serverUrl: string, userId: string) => Promise;
+ onPasswordLogin?: (
+ serverUrl: string,
+ username: string,
+ password: string,
+ ) => Promise;
+ onAddAccount?: (server: SavedServer) => void;
}
export const PreviousServersList: React.FC = ({
onServerSelect,
+ onQuickLogin,
+ onPasswordLogin,
+ onAddAccount,
}) => {
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
+ const [loadingServer, setLoadingServer] = useState(null);
+
+ // Modal states
+ const [accountsSheetOpen, setAccountsSheetOpen] = useState(false);
+ const [selectedServer, setSelectedServer] = useState(
+ null,
+ );
+ const [pinModalVisible, setPinModalVisible] = useState(false);
+ const [passwordModalVisible, setPasswordModalVisible] = useState(false);
+ const [selectedAccount, setSelectedAccount] =
+ useState(null);
const previousServers = useMemo(() => {
- return JSON.parse(_previousServers || "[]") as Server[];
+ return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]);
const { t } = useTranslation();
+ const refreshServers = () => {
+ const servers = getPreviousServers();
+ setPreviousServers(JSON.stringify(servers));
+ };
+
+ const handleAccountLogin = async (
+ server: SavedServer,
+ account: SavedServerAccount,
+ ) => {
+ switch (account.securityType) {
+ case "none":
+ // Quick login without protection
+ if (onQuickLogin) {
+ setLoadingServer(server.address);
+ try {
+ await onQuickLogin(server.address, account.userId);
+ } catch {
+ Alert.alert(
+ t("server.session_expired"),
+ t("server.please_login_again"),
+ [{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
+ );
+ } finally {
+ setLoadingServer(null);
+ }
+ }
+ break;
+
+ case "pin":
+ // Show PIN entry modal
+ setSelectedServer(server);
+ setSelectedAccount(account);
+ setPinModalVisible(true);
+ break;
+
+ case "password":
+ // Show password entry modal
+ setSelectedServer(server);
+ setSelectedAccount(account);
+ setPasswordModalVisible(true);
+ break;
+ }
+ };
+
+ const handleServerPress = async (server: SavedServer) => {
+ if (loadingServer) return; // Prevent double-tap
+
+ const accountCount = server.accounts?.length || 0;
+
+ if (accountCount === 0) {
+ // No saved accounts, go to manual login
+ onServerSelect(server);
+ } else {
+ // Has accounts, show account sheet (allows adding new account too)
+ setSelectedServer(server);
+ setAccountsSheetOpen(true);
+ }
+ };
+
+ const handlePinSuccess = async () => {
+ setPinModalVisible(false);
+ if (selectedServer && selectedAccount && onQuickLogin) {
+ setLoadingServer(selectedServer.address);
+ try {
+ await onQuickLogin(selectedServer.address, selectedAccount.userId);
+ } catch {
+ Alert.alert(
+ t("server.session_expired"),
+ t("server.please_login_again"),
+ [
+ {
+ text: t("common.ok"),
+ onPress: () => onServerSelect(selectedServer),
+ },
+ ],
+ );
+ } finally {
+ setLoadingServer(null);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }
+ }
+ };
+
+ const handlePasswordSubmit = async (password: string) => {
+ if (selectedServer && selectedAccount && onPasswordLogin) {
+ await onPasswordLogin(
+ selectedServer.address,
+ selectedAccount.username,
+ password,
+ );
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }
+ };
+
+ const handleForgotPIN = async () => {
+ if (selectedServer && selectedAccount) {
+ await deleteAccountCredential(
+ selectedServer.address,
+ selectedAccount.userId,
+ );
+ refreshServers();
+ // Go to manual login
+ onServerSelect(selectedServer);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }
+ };
+
+ const handleRemoveFirstCredential = async (serverUrl: string) => {
+ const server = previousServers.find((s) => s.address === serverUrl);
+ if (!server || server.accounts.length === 0) return;
+
+ Alert.alert(
+ t("server.remove_saved_login"),
+ t("server.remove_saved_login_description"),
+ [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.remove"),
+ style: "destructive",
+ onPress: async () => {
+ // Remove first account
+ await deleteAccountCredential(serverUrl, server.accounts[0].userId);
+ refreshServers();
+ },
+ },
+ ],
+ );
+ };
+
+ const handleRemoveServer = useCallback(
+ async (serverUrl: string) => {
+ await removeServerFromList(serverUrl);
+ refreshServers();
+ },
+ [setPreviousServers],
+ );
+
+ const renderRightActions = useCallback(
+ (serverUrl: string, swipeableRef: React.RefObject) => (
+ {
+ swipeableRef.current?.close();
+ handleRemoveServer(serverUrl);
+ }}
+ className='bg-red-600 justify-center items-center px-5'
+ >
+
+
+ ),
+ [handleRemoveServer],
+ );
+
+ const getServerSubtitle = (server: SavedServer): string | undefined => {
+ const accountCount = server.accounts?.length || 0;
+
+ if (accountCount > 1) {
+ return t("server.accounts_count", { count: accountCount });
+ }
+ if (accountCount === 1) {
+ return `${server.accounts[0].username} โข ${t("server.saved")}`;
+ }
+ return server.name ? server.address : undefined;
+ };
+
+ const getSecurityIcon = (
+ server: SavedServer,
+ ): keyof typeof Ionicons.glyphMap | null => {
+ const accountCount = server.accounts?.length || 0;
+ if (accountCount === 0) return null;
+
+ if (accountCount > 1) {
+ return "people";
+ }
+
+ const account = server.accounts[0];
+ switch (account.securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
if (!previousServers.length) return null;
return (
{previousServers.map((s) => (
- onServerSelect(s)}
- title={s.address}
- showArrow
+ server={s}
+ loadingServer={loadingServer}
+ onPress={() => handleServerPress(s)}
+ onRemoveCredential={() => handleRemoveFirstCredential(s.address)}
+ renderRightActions={renderRightActions}
+ subtitle={getServerSubtitle(s)}
+ securityIcon={getSecurityIcon(s)}
/>
))}
= ({
textColor='red'
/>
+
+ {t("server.swipe_to_remove")}
+
+
+ {/* Account Selection Sheet */}
+ {
+ if (selectedServer) {
+ handleAccountLogin(selectedServer, account);
+ }
+ }}
+ onAddAccount={() => {
+ if (selectedServer && onAddAccount) {
+ onAddAccount(selectedServer);
+ }
+ }}
+ onAccountDeleted={refreshServers}
+ />
+
+ {/* PIN Entry Modal */}
+ {
+ setPinModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ onSuccess={handlePinSuccess}
+ onForgotPIN={handleForgotPIN}
+ serverUrl={selectedServer?.address || ""}
+ userId={selectedAccount?.userId || ""}
+ username={selectedAccount?.username || ""}
+ />
+
+ {/* Password Entry Modal */}
+ {
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ onSubmit={handlePasswordSubmit}
+ username={selectedAccount?.username || ""}
+ />
);
};
+
+interface ServerItemProps {
+ server: SavedServer;
+ loadingServer: string | null;
+ onPress: () => void;
+ onRemoveCredential: () => void;
+ renderRightActions: (
+ serverUrl: string,
+ swipeableRef: React.RefObject,
+ ) => React.ReactNode;
+ subtitle?: string;
+ securityIcon: keyof typeof Ionicons.glyphMap | null;
+}
+
+const ServerItem: React.FC = ({
+ server,
+ loadingServer,
+ onPress,
+ onRemoveCredential,
+ renderRightActions,
+ subtitle,
+ securityIcon,
+}) => {
+ const swipeableRef = useRef(null);
+ const hasAccounts = server.accounts?.length > 0;
+
+ return (
+
+ renderRightActions(server.address, swipeableRef)
+ }
+ overshootRight={false}
+ >
+
+ {loadingServer === server.address ? (
+
+ ) : hasAccounts && securityIcon ? (
+ {
+ e.stopPropagation();
+ onRemoveCredential();
+ }}
+ className='p-1'
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+ ) : null}
+
+
+ );
+};
diff --git a/components/Ratings.tsx b/components/Ratings.tsx
index 5741233f..e091c64c 100644
--- a/components/Ratings.tsx
+++ b/components/Ratings.tsx
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useSeerr } from "@/hooks/useSeerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
@@ -55,23 +55,23 @@ export const Ratings: React.FC = ({ item, ...props }) => {
);
};
-export const JellyserrRatings: React.FC<{
+export const SeerrRatings: React.FC<{
result: MovieResult | TvResult | TvDetails | MovieDetails;
}> = ({ result }) => {
- const { jellyseerrApi, getMediaType } = useJellyseerr();
+ const { seerrApi, getMediaType } = useSeerr();
const mediaType = useMemo(() => getMediaType(result), [result]);
const { data, isLoading } = useQuery({
- queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
+ queryKey: ["seerr", result.id, mediaType, "ratings"],
queryFn: async () => {
return mediaType === MediaType.MOVIE
- ? jellyseerrApi?.movieRatings(result.id)
- : jellyseerrApi?.tvRatings(result.id);
+ ? seerrApi?.movieRatings(result.id)
+ : seerrApi?.tvRatings(result.id);
},
staleTime: (5).minutesToMilliseconds(),
retry: false,
- enabled: !!jellyseerrApi,
+ enabled: !!seerrApi,
});
return (
diff --git a/components/RoundButton.tsx b/components/RoundButton.tsx
index 5d490f19..5493d94f 100644
--- a/components/RoundButton.tsx
+++ b/components/RoundButton.tsx
@@ -1,7 +1,8 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import type { PropsWithChildren } from "react";
-import { Platform, TouchableOpacity, type ViewProps } from "react-native";
+import { Platform, type ViewProps } from "react-native";
+import { Pressable } from "react-native-gesture-handler";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends ViewProps {
@@ -38,7 +39,7 @@ export const RoundButton: React.FC> = ({
if (Platform.OS === "ios") {
return (
- > = ({
/>
) : null}
{children ? children : null}
-
+
);
}
if (fillColor)
return (
- > = ({
/>
) : null}
{children ? children : null}
-
+
);
if (background === false)
return (
- > = ({
/>
) : null}
{children ? children : null}
-
+
);
if (Platform.OS === "android")
return (
- > = ({
) : null}
{children ? children : null}
-
+
);
return (
-
+
> = ({
) : null}
{children ? children : null}
-
+
);
};
diff --git a/components/SaveAccountModal.tsx b/components/SaveAccountModal.tsx
new file mode 100644
index 00000000..42615c7d
--- /dev/null
+++ b/components/SaveAccountModal.tsx
@@ -0,0 +1,252 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { AccountSecurityType } from "@/utils/secureCredentials";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+import { PinInput } from "./inputs/PinInput";
+
+interface SaveAccountModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
+ username: string;
+}
+
+interface SecurityOption {
+ type: AccountSecurityType;
+ titleKey: string;
+ descriptionKey: string;
+ icon: keyof typeof Ionicons.glyphMap;
+}
+
+const SECURITY_OPTIONS: SecurityOption[] = [
+ {
+ type: "none",
+ titleKey: "save_account.no_protection",
+ descriptionKey: "save_account.no_protection_desc",
+ icon: "flash-outline",
+ },
+ {
+ type: "pin",
+ titleKey: "save_account.pin_code",
+ descriptionKey: "save_account.pin_code_desc",
+ icon: "keypad-outline",
+ },
+ {
+ type: "password",
+ titleKey: "save_account.password",
+ descriptionKey: "save_account.password_desc",
+ icon: "lock-closed-outline",
+ },
+];
+
+export const SaveAccountModal: React.FC = ({
+ visible,
+ onClose,
+ onSave,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+ const [selectedType, setSelectedType] = useState("none");
+ const [pinCode, setPinCode] = useState("");
+ const [pinError, setPinError] = useState(null);
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["70%"]),
+ [isAndroid],
+ );
+
+ useEffect(() => {
+ if (visible) {
+ bottomSheetModalRef.current?.present();
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [visible]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ resetState();
+ onClose();
+ }
+ },
+ [onClose],
+ );
+
+ const resetState = () => {
+ setSelectedType("none");
+ setPinCode("");
+ setPinError(null);
+ };
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleOptionSelect = (type: AccountSecurityType) => {
+ setSelectedType(type);
+ setPinCode("");
+ setPinError(null);
+ };
+
+ const handleSave = () => {
+ if (selectedType === "pin") {
+ if (pinCode.length !== 4) {
+ setPinError(t("pin.enter_4_digits") || "Enter 4 digits");
+ return;
+ }
+ onSave("pin", pinCode);
+ } else {
+ onSave(selectedType);
+ }
+ resetState();
+ };
+
+ const handleCancel = () => {
+ resetState();
+ onClose();
+ };
+
+ const canSave = () => {
+ if (selectedType === "pin") {
+ return pinCode.length === 4;
+ }
+ return true;
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("save_account.title")}
+
+ {username}
+
+
+ {/* PIN Entry Step */}
+ {selectedType === "pin" ? (
+
+
+
+ {t("pin.setup_pin")}
+
+
+ {pinError && (
+
+ {pinError}
+
+ )}
+
+
+ ) : (
+ /* Security Options */
+
+
+ {t("save_account.security_option")}
+
+
+ {SECURITY_OPTIONS.map((option, index) => (
+ handleOptionSelect(option.type)}
+ className={`flex-row items-center p-4 ${
+ index < SECURITY_OPTIONS.length - 1
+ ? "border-b border-neutral-700"
+ : ""
+ }`}
+ >
+
+
+
+
+
+ {t(option.titleKey)}
+
+
+ {t(option.descriptionKey)}
+
+
+
+ {selectedType === option.type && (
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Buttons */}
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx
index b73e5518..7ed99535 100644
--- a/components/SimilarItems.tsx
+++ b/components/SimilarItems.tsx
@@ -7,6 +7,10 @@ import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
+const POSTER_CAROUSEL_HEIGHT = 220;
+
import { HorizontalScroll } from "./common/HorizontalScroll";
import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
@@ -53,7 +57,7 @@ export const SimilarItems: React.FC = ({
(
= ({
if (currentItem) {
setSelectedOptions({
bitrate: defaultBitrate,
- mediaSource: defaultMediaSource,
+ mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
});
diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx
index 686cab5d..8818a9a0 100644
--- a/components/common/HeaderBackButton.tsx
+++ b/components/common/HeaderBackButton.tsx
@@ -1,42 +1,36 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
-import { useRouter } from "expo-router";
-import {
- Platform,
- TouchableOpacity,
- type TouchableOpacityProps,
-} from "react-native";
+import { Platform } from "react-native";
+import { Pressable, type PressableProps } from "react-native-gesture-handler";
+import useRouter from "@/hooks/useAppRouter";
interface Props extends BlurViewProps {
background?: "blur" | "transparent";
- touchableOpacityProps?: TouchableOpacityProps;
+ pressableProps?: Omit;
}
export const HeaderBackButton: React.FC = ({
background = "transparent",
- touchableOpacityProps,
+ pressableProps,
...props
}) => {
const router = useRouter();
if (Platform.OS === "ios") {
return (
- router.back()}
className='flex items-center justify-center w-9 h-9'
- {...touchableOpacityProps}
+ {...pressableProps}
>
-
+
);
}
if (background === "transparent" && Platform.OS !== "android")
return (
- router.back()}
- {...touchableOpacityProps}
- >
+ router.back()} {...pressableProps}>
= ({
color='white'
/>
-
+
);
return (
- router.back()}
className=' rounded-full p-2'
- {...touchableOpacityProps}
+ {...pressableProps}
>
= ({
size={24}
color='white'
/>
-
+
);
};
diff --git a/components/common/HorizontalScroll.tsx b/components/common/HorizontalScroll.tsx
index 6c6af8e7..e907f52f 100644
--- a/components/common/HorizontalScroll.tsx
+++ b/components/common/HorizontalScroll.tsx
@@ -58,7 +58,7 @@ export const HorizontalScroll = (
if (!data || loading) {
return (
-
+
diff --git a/components/common/LargePoster.tsx b/components/common/LargePoster.tsx
deleted file mode 100644
index ab3b16fb..00000000
--- a/components/common/LargePoster.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Image } from "expo-image";
-import { View } from "react-native";
-
-export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
- if (!url)
- return (
-
-
-
- );
-
- return (
-
-
-
- );
-};
diff --git a/components/common/SectionHeader.tsx b/components/common/SectionHeader.tsx
new file mode 100644
index 00000000..aa9efc43
--- /dev/null
+++ b/components/common/SectionHeader.tsx
@@ -0,0 +1,42 @@
+import { TouchableOpacity, View } from "react-native";
+import { Colors } from "@/constants/Colors";
+import { Text } from "./Text";
+
+type Props = {
+ title: string;
+ actionLabel?: string;
+ actionDisabled?: boolean;
+ onPressAction?: () => void;
+};
+
+export const SectionHeader: React.FC = ({
+ title,
+ actionLabel,
+ actionDisabled = false,
+ onPressAction,
+}) => {
+ const shouldShowAction = Boolean(actionLabel) && Boolean(onPressAction);
+
+ return (
+
+ {title}
+ {shouldShowAction && (
+
+
+ {actionLabel}
+
+
+ )}
+
+ );
+};
diff --git a/components/common/JellyseerrItemRouter.tsx b/components/common/SeerrItemRouter.tsx
similarity index 87%
rename from components/common/JellyseerrItemRouter.tsx
rename to components/common/SeerrItemRouter.tsx
index 2fdaa801..184ef490 100644
--- a/components/common/JellyseerrItemRouter.tsx
+++ b/components/common/SeerrItemRouter.tsx
@@ -1,7 +1,8 @@
-import { useRouter, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import type React from "react";
import { type PropsWithChildren } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
@@ -20,7 +21,7 @@ interface Props extends TouchableOpacityProps {
mediaType: MediaType;
}
-export const TouchableJellyseerrRouter: React.FC> = ({
+export const TouchableSeerrRouter: React.FC> = ({
result,
mediaTitle,
releaseYear,
@@ -42,7 +43,7 @@ export const TouchableJellyseerrRouter: React.FC> = ({
if (!result) return;
router.push({
- pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
+ pathname: `/(auth)/(tabs)/${from}/seerr/page`,
// @ts-expect-error
params: {
...result,
diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx
index e95fd5e7..2c85e094 100644
--- a/components/common/TouchableItemRouter.tsx
+++ b/components/common/TouchableItemRouter.tsx
@@ -1,14 +1,16 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useRouter, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
+import { useDownload } from "@/providers/DownloadProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
- isOffline?: boolean;
}
export const itemRouter = (item: BaseItemDto, from: string) => {
@@ -16,6 +18,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/livetv`;
}
+ if ("CollectionType" in item && item.CollectionType === "music") {
+ return `/(auth)/(tabs)/(libraries)/music/${item.Id}`;
+ }
+
if (item.Type === "Series") {
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
@@ -50,6 +56,13 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
};
}
+ if ("CollectionType" in item && item.CollectionType === "music") {
+ return {
+ pathname: "/music/[libraryId]" as const,
+ params: { libraryId: item.Id! },
+ };
+ }
+
if (item.Type === "Series") {
return {
pathname: "/series/[id]" as const,
@@ -71,13 +84,49 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
};
}
- if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
+ if (item.Type === "CollectionFolder") {
return {
pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! },
};
}
+ // Music types - use shared routes for proper back navigation
+ if (item.Type === "MusicArtist") {
+ return {
+ pathname: "/music/artist/[artistId]" as const,
+ params: { artistId: item.Id! },
+ };
+ }
+
+ if (item.Type === "MusicAlbum") {
+ return {
+ pathname: "/music/album/[albumId]" as const,
+ params: { albumId: item.Id! },
+ };
+ }
+
+ if (item.Type === "Audio") {
+ // Navigate to the album if available, otherwise to the item page
+ if (item.AlbumId) {
+ return {
+ pathname: "/music/album/[albumId]" as const,
+ params: { albumId: item.AlbumId },
+ };
+ }
+ return {
+ pathname: "/items/page" as const,
+ params: { id: item.Id! },
+ };
+ }
+
+ if (item.Type === "Playlist") {
+ return {
+ pathname: "/music/playlist/[playlistId]" as const,
+ params: { playlistId: item.Id! },
+ };
+ }
+
// Default case - items page
return {
pathname: "/items/page" as const,
@@ -87,18 +136,31 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
export const TouchableItemRouter: React.FC> = ({
item,
- isOffline = false,
children,
...props
}) => {
- const router = useRouter();
const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
+ const router = useRouter();
+ const isOffline = useOfflineMode();
+ const { deleteFile } = useDownload();
const from = (segments as string[])[2] || "(home)";
+ const handlePress = useCallback(() => {
+ // Force music libraries to navigate via the explicit string route.
+ // This avoids losing the dynamic [libraryId] param when going through a nested navigator.
+ if ("CollectionType" in item && item.CollectionType === "music") {
+ router.push(itemRouter(item, from) as any);
+ return;
+ }
+
+ const navigation = getItemNavigation(item, from);
+ router.push(navigation as any);
+ }, [from, item, router]);
+
const showActionSheet = useCallback(() => {
if (
!(
@@ -108,18 +170,24 @@ export const TouchableItemRouter: React.FC> = ({
)
)
return;
- const options = [
+
+ const options: string[] = [
"Mark as Played",
"Mark as Not Played",
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
+ ...(isOffline ? ["Delete Download"] : []),
"Cancel",
];
- const cancelButtonIndex = 3;
+ const cancelButtonIndex = options.length - 1;
+ const destructiveButtonIndex = isOffline
+ ? cancelButtonIndex - 1
+ : undefined;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
+ destructiveButtonIndex,
},
async (selectedIndex) => {
if (selectedIndex === 0) {
@@ -128,31 +196,32 @@ export const TouchableItemRouter: React.FC> = ({
await markAsPlayedStatus(false);
} else if (selectedIndex === 2) {
toggleFavorite();
+ } else if (isOffline && selectedIndex === 3 && item.Id) {
+ deleteFile(item.Id);
}
},
);
- }, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
+ }, [
+ showActionSheetWithOptions,
+ isFavorite,
+ markAsPlayedStatus,
+ toggleFavorite,
+ isOffline,
+ deleteFile,
+ item.Id,
+ ]);
if (
from === "(home)" ||
from === "(search)" ||
from === "(libraries)" ||
- from === "(favorites)"
+ from === "(favorites)" ||
+ from === "(watchlists)"
)
return (
{
- if (isOffline) {
- // For offline mode, we still need to use query params
- const url = `${itemRouter(item, from)}&offline=true`;
- router.push(url as any);
- return;
- }
-
- const navigation = getItemNavigation(item, from);
- router.push(navigation as any);
- }}
+ onPress={handlePress}
{...props}
>
{children}
diff --git a/components/common/VerticalSkeleton.tsx b/components/common/VerticalSkeleton.tsx
deleted file mode 100644
index 02a8a256..00000000
--- a/components/common/VerticalSkeleton.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { View, type ViewProps } from "react-native";
-
-interface Props extends ViewProps {
- index: number;
-}
-
-export const VerticalSkeleton: React.FC = ({ index, ...props }) => {
- return (
-
-
-
-
-
-
- );
-};
diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx
index 5453f32a..66f2a81b 100644
--- a/components/downloads/DownloadCard.tsx
+++ b/components/downloads/DownloadCard.tsx
@@ -1,7 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
-import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
-import { useRouter } from "expo-router";
import { t } from "i18next";
import { useMemo } from "react";
import {
@@ -12,6 +10,8 @@ import {
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types";
@@ -37,7 +37,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { cancelDownload } = useDownload();
const router = useRouter();
- const queryClient = useQueryClient();
+ const queryClient = useNetworkAwareQueryClient();
const handleDelete = async (id: string) => {
try {
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index 3c57a17a..f907bacf 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -61,7 +61,6 @@ export const EpisodeCard: React.FC = ({ item }) => {
return (
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index f02fe796..9d805ddb 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -67,7 +67,7 @@ export const MovieCard: React.FC = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
-
+
{base64Image ? (
= ({ items }) => {
const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet();
+ const router = useRouter();
const base64Image = useMemo(() => {
return storage.getString(items[0].SeriesId!);
@@ -46,7 +47,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return (
router.push(`/downloads/${items[0].SeriesId}`)}
+ onPress={() =>
+ router.push({
+ pathname: "/series/[id]",
+ params: { id: items[0].SeriesId!, offline: "true" },
+ })
+ }
onLongPress={showActionSheet}
>
{base64Image ? (
diff --git a/components/filters/ResetFiltersButton.tsx b/components/filters/ResetFiltersButton.tsx
index c0cc6d68..856ccd3b 100644
--- a/components/filters/ResetFiltersButton.tsx
+++ b/components/filters/ResetFiltersButton.tsx
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import {
+ filterByAtom,
genreFilterAtom,
tagsFilterAtom,
yearFilterAtom,
@@ -13,11 +14,13 @@ export const ResetFiltersButton: React.FC = ({ ...props }) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
+ const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
if (
selectedGenres.length === 0 &&
selectedTags.length === 0 &&
- selectedYears.length === 0
+ selectedYears.length === 0 &&
+ selectedFilters.length === 0
) {
return null;
}
@@ -28,6 +31,7 @@ export const ResetFiltersButton: React.FC = ({ ...props }) => {
setSelectedGenres([]);
setSelectedTags([]);
setSelectedYears([]);
+ setSelectedFilters([]);
}}
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
{...props}
diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx
index b1b32d95..84fa36b9 100644
--- a/components/home/Favorites.tsx
+++ b/components/home/Favorites.tsx
@@ -9,6 +9,7 @@ import { Text, View } from "react-native";
// PNG ASSET
import heart from "@/assets/icons/heart.fill.png";
import { Colors } from "@/constants/Colors";
+import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
@@ -22,8 +23,10 @@ type FavoriteTypes =
type EmptyState = Record;
export const Favorites = () => {
+ const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const pageSize = 20;
const [emptyState, setEmptyState] = useState({
Series: false,
Movie: false,
@@ -91,35 +94,77 @@ export const Favorites = () => {
const fetchFavoriteSeries = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Series", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Series", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoriteMovies = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Movie", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Movie", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoriteEpisodes = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Episode", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Episode", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoriteVideos = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Video", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Video", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoriteBoxsets = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("BoxSet", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("BoxSet", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoritePlaylists = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Playlist", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Playlist", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
+ const handleSeeAllSeries = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Series", title: t("favorites.series") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllMovies = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Movie", title: t("favorites.movies") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllEpisodes = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Episode", title: t("favorites.episodes") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllVideos = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Video", title: t("favorites.videos") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllBoxsets = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "BoxSet", title: t("favorites.boxsets") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllPlaylists = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Playlist", title: t("favorites.playlists") },
+ } as any);
+ }, [router]);
+
return (
{areAllEmpty() && (
@@ -143,6 +188,8 @@ export const Favorites = () => {
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
+ pageSize={pageSize}
+ onPressSeeAll={handleSeeAllSeries}
/>
{
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
+ pageSize={pageSize}
+ onPressSeeAll={handleSeeAllMovies}
/>
);
diff --git a/components/home/Home.tsx b/components/home/Home.tsx
index 5803083d..1da3b358 100644
--- a/components/home/Home.tsx
+++ b/components/home/Home.tsx
@@ -12,7 +12,7 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
-import { useNavigation, useRouter, useSegments } from "expo-router";
+import { useNavigation, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -21,22 +21,28 @@ import {
Platform,
RefreshControl,
ScrollView,
- TouchableOpacity,
View,
} from "react-native";
+import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
+import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
+import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
+import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
+import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
+import { storage } from "@/utils/mmkv";
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
@@ -45,12 +51,15 @@ type InfiniteScrollingCollectionListSection = {
queryFn: QueryFunction;
orientation?: "horizontal" | "vertical";
pageSize?: number;
+ priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority
+ parentId?: string; // Library ID for "See All" navigation
};
type MediaListSectionType = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction;
+ priority?: 1 | 2;
};
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
@@ -74,7 +83,22 @@ export const Home = () => {
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
- const [scrollY, setScrollY] = useState(0);
+ const [loadedSections, setLoadedSections] = useState>(new Set());
+ const { showIntro } = useIntroSheet();
+
+ // Show intro modal on first launch
+ useEffect(() => {
+ const hasShownIntro = storage.getBoolean("hasShownIntro");
+ if (!hasShownIntro) {
+ const timer = setTimeout(() => {
+ showIntro();
+ }, 1000);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }
+ }, [showIntro]);
useEffect(() => {
if (isConnected && !prevIsConnected.current) {
@@ -97,7 +121,7 @@ export const Home = () => {
}
navigation.setOptions({
headerLeft: () => (
- {
router.push("/(auth)/downloads");
}}
@@ -109,7 +133,7 @@ export const Home = () => {
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
-
+
),
});
}, [navigation, router, hasDownloads]);
@@ -172,6 +196,7 @@ export const Home = () => {
const refetch = async () => {
setLoading(true);
+ setLoadedSections(new Set());
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
@@ -194,10 +219,10 @@ export const Home = () => {
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
- limit: 100, // Fetch a larger set for pagination
- fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
+ limit: 10,
+ fields: ["PrimaryImageAspectRatio"],
imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
@@ -208,6 +233,7 @@ export const Home = () => {
},
type: "InfiniteScrollingCollectionList",
pageSize,
+ parentId,
}),
[api, user?.Id],
);
@@ -236,64 +262,143 @@ export const Home = () => {
);
});
+ // Helper to sort items by most recent activity
+ const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
+ return items.sort((a, b) => {
+ const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
+ const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
+ return new Date(dateB).getTime() - new Date(dateA).getTime();
+ });
+ };
+
+ // Helper to deduplicate items by ID
+ const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
+ const seen = new Set();
+ return items.filter((item) => {
+ if (!item.Id || seen.has(item.Id)) return false;
+ seen.add(item.Id);
+ return true;
+ });
+ };
+
+ // Build the first sections based on merge setting
+ const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
+ ? [
+ {
+ title: t("home.continue_and_next_up"),
+ queryKey: ["home", "continueAndNextUp"],
+ queryFn: async ({ pageParam = 0 }) => {
+ // Fetch both in parallel
+ const [resumeResponse, nextUpResponse] = await Promise.all([
+ getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ startIndex: 0,
+ limit: 20,
+ }),
+ getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ startIndex: 0,
+ limit: 20,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableResumable: false,
+ }),
+ ]);
+
+ const resumeItems = resumeResponse.data.Items || [];
+ const nextUpItems = nextUpResponse.data.Items || [];
+
+ // Combine, sort by recent activity, deduplicate
+ const combined = [...resumeItems, ...nextUpItems];
+ const sorted = sortByRecentActivity(combined);
+ const deduplicated = deduplicateById(sorted);
+
+ // Paginate client-side
+ return deduplicated.slice(pageParam, pageParam + 10);
+ },
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ priority: 1,
+ },
+ ]
+ : [
+ {
+ title: t("home.continue_watching"),
+ queryKey: ["home", "resumeItems"],
+ queryFn: async ({ pageParam = 0 }) =>
+ (
+ await getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ startIndex: pageParam,
+ limit: 10,
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ priority: 1,
+ },
+ {
+ title: t("home.next_up"),
+ queryKey: ["home", "nextUp-all"],
+ queryFn: async ({ pageParam = 0 }) =>
+ (
+ await getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: 10,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableResumable: false,
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ priority: 1,
+ },
+ ];
+
const ss: Section[] = [
- {
- title: t("home.continue_watching"),
- queryKey: ["home", "resumeItems"],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getItemsApi(api).getResumeItems({
- userId: user.Id,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- includeItemTypes: ["Movie", "Series", "Episode"],
- fields: ["Genres"],
- startIndex: pageParam,
- limit: 10,
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "horizontal",
- pageSize: 10,
- },
- {
- title: t("home.next_up"),
- queryKey: ["home", "nextUp-all"],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getTvShowsApi(api).getNextUp({
- userId: user?.Id,
- fields: ["MediaSourceCount", "Genres"],
- startIndex: pageParam,
- limit: 10,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- enableResumable: false,
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "horizontal",
- pageSize: 10,
- },
- ...latestMediaViews,
- {
- title: t("home.suggested_movies"),
- queryKey: ["home", "suggestedMovies", user?.Id],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getSuggestionsApi(api).getSuggestions({
- userId: user?.Id,
- startIndex: pageParam,
- limit: 10,
- mediaType: ["Video"],
- type: ["Movie"],
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "vertical",
- pageSize: 10,
- },
+ ...firstSections,
+ ...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
+ // Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
+ ...(!settings?.streamyStatsMovieRecommendations
+ ? [
+ {
+ title: t("home.suggested_movies"),
+ queryKey: ["home", "suggestedMovies", user?.Id],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
+ (
+ await getSuggestionsApi(api).getSuggestions({
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: 10,
+ mediaType: ["Video"],
+ type: ["Movie"],
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList" as const,
+ orientation: "vertical" as const,
+ pageSize: 10,
+ priority: 2 as const,
+ },
+ ]
+ : []),
];
return ss;
- }, [api, user?.Id, collections, t, createCollectionConfig]);
+ }, [
+ api,
+ user?.Id,
+ collections,
+ t,
+ createCollectionConfig,
+ settings?.streamyStatsMovieRecommendations,
+ settings.mergeNextUpAndContinueWatching,
+ ]);
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
@@ -322,10 +427,9 @@ export const Home = () => {
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
- fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
@@ -338,7 +442,7 @@ export const Home = () => {
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
- limit: section.latest?.limit || 100, // Fetch larger set
+ limit: section.latest?.limit || 10,
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
@@ -367,6 +471,8 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical",
pageSize,
+ // First 2 custom sections are high priority
+ priority: index < 2 ? 1 : 2,
});
});
return ss;
@@ -374,6 +480,25 @@ export const Home = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
+ // Get all high priority section keys and check if all have loaded
+ const highPrioritySectionKeys = useMemo(() => {
+ return sections
+ .filter((s) => s.priority === 1)
+ .map((s) => s.queryKey.join("-"));
+ }, [sections]);
+
+ const allHighPriorityLoaded = useMemo(() => {
+ return highPrioritySectionKeys.every((key) => loadedSections.has(key));
+ }, [highPrioritySectionKeys, loadedSections]);
+
+ const markSectionLoaded = useCallback(
+ (queryKey: (string | undefined | null)[]) => {
+ const key = queryKey.join("-");
+ setLoadedSections((prev) => new Set(prev).add(key));
+ },
+ [],
+ );
+
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
@@ -451,10 +576,6 @@ export const Home = () => {
ref={scrollRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
- onScroll={(event) => {
- setScrollY(event.nativeEvent.contentOffset.y - 500);
- }}
- scrollEventThrottle={16}
refreshControl={
{
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{sections.map((section, index) => {
+ // Render Streamystats sections after Continue Watching and Next Up
+ // When merged, they appear after index 0; otherwise after index 1
+ const streamystatsIndex = settings.mergeNextUpAndContinueWatching
+ ? 0
+ : 1;
+ const hasStreamystatsContent =
+ settings.streamyStatsMovieRecommendations ||
+ settings.streamyStatsSeriesRecommendations ||
+ settings.streamyStatsPromotedWatchlists;
+ const streamystatsSections =
+ index === streamystatsIndex && hasStreamystatsContent ? (
+
+ {settings.streamyStatsMovieRecommendations && (
+
+ )}
+ {settings.streamyStatsSeriesRecommendations && (
+
+ )}
+ {settings.streamyStatsPromotedWatchlists && (
+
+ )}
+
+ ) : null;
if (section.type === "InfiniteScrollingCollectionList") {
+ const isHighPriority = section.priority === 1;
+ const handleSeeAll = section.parentId
+ ? () => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
+ params: {
+ libraryId: section.parentId!,
+ sortBy: SortByOption.DateCreated,
+ sortOrder: SortOrderOption.Descending,
+ },
+ } as any);
+ }
+ : undefined;
return (
-
+
+ markSectionLoaded(section.queryKey)
+ : undefined
+ }
+ onPressSeeAll={handleSeeAll}
+ />
+ {streamystatsSections}
+
);
}
if (section.type === "MediaListSection") {
return (
-
+
+
+ {streamystatsSections}
+
);
}
return null;
diff --git a/components/home/HomeWithCarousel.tsx b/components/home/HomeWithCarousel.tsx
index 43e7fefb..9bddb3f9 100644
--- a/components/home/HomeWithCarousel.tsx
+++ b/components/home/HomeWithCarousel.tsx
@@ -12,7 +12,7 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
-import { useNavigation, useRouter, useSegments } from "expo-router";
+import { useNavigation, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -30,9 +30,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
+import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
+import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
+import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
@@ -241,64 +244,143 @@ export const HomeWithCarousel = () => {
);
});
+ // Helper to sort items by most recent activity
+ const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
+ return items.sort((a, b) => {
+ const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
+ const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
+ return new Date(dateB).getTime() - new Date(dateA).getTime();
+ });
+ };
+
+ // Helper to deduplicate items by ID
+ const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
+ const seen = new Set();
+ return items.filter((item) => {
+ if (!item.Id || seen.has(item.Id)) return false;
+ seen.add(item.Id);
+ return true;
+ });
+ };
+
+ // Build the first sections based on merge setting
+ const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
+ ? [
+ {
+ title: t("home.continue_and_next_up"),
+ queryKey: ["home", "continueAndNextUp"],
+ queryFn: async ({ pageParam = 0 }) => {
+ // Fetch both in parallel
+ const [resumeResponse, nextUpResponse] = await Promise.all([
+ getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ fields: ["Genres"],
+ startIndex: 0,
+ limit: 20,
+ }),
+ getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ fields: ["MediaSourceCount", "Genres"],
+ startIndex: 0,
+ limit: 20,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ enableResumable: false,
+ }),
+ ]);
+
+ const resumeItems = resumeResponse.data.Items || [];
+ const nextUpItems = nextUpResponse.data.Items || [];
+
+ // Combine, sort by recent activity, deduplicate
+ const combined = [...resumeItems, ...nextUpItems];
+ const sorted = sortByRecentActivity(combined);
+ const deduplicated = deduplicateById(sorted);
+
+ // Paginate client-side
+ return deduplicated.slice(pageParam, pageParam + 10);
+ },
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ },
+ ]
+ : [
+ {
+ title: t("home.continue_watching"),
+ queryKey: ["home", "resumeItems"],
+ queryFn: async ({ pageParam = 0 }) =>
+ (
+ await getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ fields: ["Genres"],
+ startIndex: pageParam,
+ limit: 10,
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ },
+ {
+ title: t("home.next_up"),
+ queryKey: ["home", "nextUp-all"],
+ queryFn: async ({ pageParam = 0 }) =>
+ (
+ await getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ fields: ["MediaSourceCount", "Genres"],
+ startIndex: pageParam,
+ limit: 10,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ enableResumable: false,
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ },
+ ];
+
const ss: Section[] = [
- {
- title: t("home.continue_watching"),
- queryKey: ["home", "resumeItems"],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getItemsApi(api).getResumeItems({
- userId: user.Id,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- includeItemTypes: ["Movie", "Series", "Episode"],
- fields: ["Genres"],
- startIndex: pageParam,
- limit: 10,
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "horizontal",
- pageSize: 10,
- },
- {
- title: t("home.next_up"),
- queryKey: ["home", "nextUp-all"],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getTvShowsApi(api).getNextUp({
- userId: user?.Id,
- fields: ["MediaSourceCount", "Genres"],
- startIndex: pageParam,
- limit: 10,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- enableResumable: false,
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "horizontal",
- pageSize: 10,
- },
+ ...firstSections,
...latestMediaViews,
- {
- title: t("home.suggested_movies"),
- queryKey: ["home", "suggestedMovies", user?.Id],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getSuggestionsApi(api).getSuggestions({
- userId: user?.Id,
- startIndex: pageParam,
- limit: 10,
- mediaType: ["Video"],
- type: ["Movie"],
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "vertical",
- pageSize: 10,
- },
+ // Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
+ ...(!settings?.streamyStatsMovieRecommendations
+ ? [
+ {
+ title: t("home.suggested_movies"),
+ queryKey: ["home", "suggestedMovies", user?.Id],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
+ (
+ await getSuggestionsApi(api).getSuggestions({
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: 10,
+ mediaType: ["Video"],
+ type: ["Movie"],
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList" as const,
+ orientation: "vertical" as const,
+ pageSize: 10,
+ },
+ ]
+ : []),
];
return ss;
- }, [api, user?.Id, collections, t, createCollectionConfig]);
+ }, [
+ api,
+ user?.Id,
+ collections,
+ t,
+ createCollectionConfig,
+ settings?.streamyStatsMovieRecommendations,
+ settings.mergeNextUpAndContinueWatching,
+ ]);
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
@@ -477,28 +559,68 @@ export const HomeWithCarousel = () => {
>
{sections.map((section, index) => {
+ // Render Streamystats sections after Continue Watching and Next Up
+ // When merged, they appear after index 0; otherwise after index 1
+ const streamystatsIndex = settings.mergeNextUpAndContinueWatching
+ ? 0
+ : 1;
+ const hasStreamystatsContent =
+ settings.streamyStatsMovieRecommendations ||
+ settings.streamyStatsSeriesRecommendations ||
+ settings.streamyStatsPromotedWatchlists;
+ const streamystatsSections =
+ index === streamystatsIndex && hasStreamystatsContent
+ ? [
+ settings.streamyStatsMovieRecommendations && (
+
+ ),
+ settings.streamyStatsSeriesRecommendations && (
+
+ ),
+ settings.streamyStatsPromotedWatchlists && (
+
+ ),
+ ].filter(Boolean)
+ : null;
+
if (section.type === "InfiniteScrollingCollectionList") {
return (
-
+
+
+ {streamystatsSections}
+
);
}
if (section.type === "MediaListSection") {
return (
-
+
+
+ {streamystatsSections}
+
);
}
return null;
diff --git a/components/home/InfiniteScrollingCollectionList.tsx b/components/home/InfiniteScrollingCollectionList.tsx
index 73a1c21d..e5e5b7a6 100644
--- a/components/home/InfiniteScrollingCollectionList.tsx
+++ b/components/home/InfiniteScrollingCollectionList.tsx
@@ -4,7 +4,7 @@ import {
type QueryKey,
useInfiniteQuery,
} from "@tanstack/react-query";
-import { useMemo } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -12,6 +12,7 @@ import {
View,
type ViewProps,
} from "react-native";
+import { SectionHeader } from "@/components/common/SectionHeader";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { Colors } from "../../constants/Colors";
@@ -28,6 +29,9 @@ interface Props extends ViewProps {
queryFn: QueryFunction;
hideIfEmpty?: boolean;
pageSize?: number;
+ onPressSeeAll?: () => void;
+ enabled?: boolean;
+ onLoaded?: () => void;
}
export const InfiniteScrollingCollectionList: React.FC = ({
@@ -38,32 +42,67 @@ export const InfiniteScrollingCollectionList: React.FC = ({
queryKey,
hideIfEmpty = false,
pageSize = 10,
+ onPressSeeAll,
+ enabled = true,
+ onLoaded,
...props
}) => {
- const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
- useInfiniteQuery({
- queryKey: queryKey,
- queryFn: ({ pageParam = 0, ...context }) =>
- queryFn({ ...context, queryKey, pageParam }),
- getNextPageParam: (lastPage, allPages) => {
- // If the last page has fewer items than pageSize, we've reached the end
- if (lastPage.length < pageSize) {
- return undefined;
- }
- // Otherwise, return the next start index
- return allPages.length * pageSize;
- },
- initialPageParam: 0,
- staleTime: 60 * 1000, // 1 minute
- refetchOnMount: false,
- refetchOnWindowFocus: false,
- refetchOnReconnect: true,
- });
+ const effectivePageSize = Math.max(1, pageSize);
+ const hasCalledOnLoaded = useRef(false);
+ const {
+ data,
+ isLoading,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ isSuccess,
+ } = useInfiniteQuery({
+ queryKey: queryKey,
+ queryFn: ({ pageParam = 0, ...context }) =>
+ queryFn({ ...context, queryKey, pageParam }),
+ getNextPageParam: (lastPage, allPages) => {
+ // If the last page has fewer items than pageSize, we've reached the end
+ if (lastPage.length < effectivePageSize) {
+ return undefined;
+ }
+ // Otherwise, return the next start index based on how many items we already loaded.
+ // This avoids overlaps if the server/page size differs from our configured page size.
+ return allPages.reduce((acc, page) => acc + page.length, 0);
+ },
+ initialPageParam: 0,
+ staleTime: 60 * 1000, // 1 minute
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: true,
+ enabled,
+ });
+
+ // Notify parent when data has loaded
+ useEffect(() => {
+ if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
+ hasCalledOnLoaded.current = true;
+ onLoaded();
+ }
+ }, [isSuccess, onLoaded]);
const { t } = useTranslation();
- // Flatten all pages into a single array
- const allItems = data?.pages.flat() || [];
+ // Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
+ const allItems = useMemo(() => {
+ const items = data?.pages.flat() ?? [];
+ const seen = new Set();
+ const deduped: BaseItemDto[] = [];
+
+ for (const item of items) {
+ const id = item.Id;
+ if (!id) continue;
+ if (seen.has(id)) continue;
+ seen.add(id);
+ deduped.push(item);
+ }
+
+ return deduped;
+ }, [data]);
const snapOffsets = useMemo(() => {
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
@@ -90,9 +129,12 @@ export const InfiniteScrollingCollectionList: React.FC = ({
return (
-
- {title}
-
+
{isLoading === false && allItems.length === 0 && (
{t("home.no_items")}
@@ -136,11 +178,11 @@ export const InfiniteScrollingCollectionList: React.FC = ({
decelerationRate='fast'
>
- {allItems.map((item) => (
+ {allItems.map((item, index) => (
diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx
index 913e024b..cf79af73 100644
--- a/components/home/LargeMovieCarousel.tsx
+++ b/components/home/LargeMovieCarousel.tsx
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
-import { useRouter, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react";
import { Dimensions, View, type ViewProps } from "react-native";
@@ -16,6 +16,7 @@ import Carousel, {
type ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
+import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx
index 54542c44..3d64466f 100644
--- a/components/home/ScrollingCollectionList.tsx
+++ b/components/home/ScrollingCollectionList.tsx
@@ -21,7 +21,6 @@ interface Props extends ViewProps {
queryKey: QueryKey;
queryFn: QueryFunction;
hideIfEmpty?: boolean;
- isOffline?: boolean;
scrollY?: number; // For lazy loading
enableLazyLoading?: boolean; // Enable/disable lazy loading
}
@@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC = ({
queryFn,
queryKey,
hideIfEmpty = false,
- isOffline = false,
scrollY = 0,
enableLazyLoading = false,
...props
@@ -106,7 +104,6 @@ export const ScrollingCollectionList: React.FC = ({
= ({
+ watchlist,
+ jellyfinServerId,
+ ...props
+}) => {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+ const router = useRouter();
+ const { t } = useTranslation();
+
+ const { data: items, isLoading } = useQuery({
+ queryKey: [
+ "streamystats",
+ "watchlist",
+ watchlist.id,
+ jellyfinServerId,
+ settings?.streamyStatsServerUrl,
+ ],
+ queryFn: async (): Promise => {
+ if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
+ return [];
+ }
+
+ const streamystatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
+ watchlistId: watchlist.id,
+ jellyfinServerId,
+ });
+
+ const itemIds = watchlistDetail.data?.items;
+ if (!itemIds?.length) {
+ return [];
+ }
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ ids: itemIds,
+ fields: ["PrimaryImageAspectRatio", "Genres"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ });
+
+ return response.data.Items || [];
+ },
+ enabled:
+ Boolean(settings?.streamyStatsServerUrl) &&
+ Boolean(api?.accessToken) &&
+ Boolean(user?.Id),
+ staleTime: 5 * 60 * 1000,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ });
+
+ const snapOffsets = useMemo(() => {
+ return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
+ }, [items]);
+
+ const handleSeeAll = () => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(watchlists)/[watchlistId]",
+ params: { watchlistId: watchlist.id.toString() },
+ } as any);
+ };
+
+ if (!isLoading && (!items || items.length === 0)) return null;
+
+ return (
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Loading...
+
+
+
+ ))}
+
+ ) : (
+
+
+ {items?.map((item) => (
+
+ {item.Type === "Movie" && }
+ {item.Type === "Series" && }
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+interface StreamystatsPromotedWatchlistsProps extends ViewProps {
+ enabled?: boolean;
+}
+
+export const StreamystatsPromotedWatchlists: React.FC<
+ StreamystatsPromotedWatchlistsProps
+> = ({ enabled = true, ...props }) => {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+
+ const streamyStatsEnabled = useMemo(() => {
+ return Boolean(settings?.streamyStatsServerUrl);
+ }, [settings?.streamyStatsServerUrl]);
+
+ // Fetch server info to get the Jellyfin server ID
+ const { data: serverInfo } = useQuery({
+ queryKey: ["jellyfin", "serverInfo"],
+ queryFn: async (): Promise => {
+ if (!api) return null;
+ const response = await getSystemApi(api).getPublicSystemInfo();
+ return response.data;
+ },
+ enabled: enabled && Boolean(api) && streamyStatsEnabled,
+ staleTime: 60 * 60 * 1000,
+ });
+
+ const jellyfinServerId = serverInfo?.Id;
+
+ const {
+ data: watchlists,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: [
+ "streamystats",
+ "promotedWatchlists",
+ jellyfinServerId,
+ settings?.streamyStatsServerUrl,
+ ],
+ queryFn: async (): Promise => {
+ if (
+ !settings?.streamyStatsServerUrl ||
+ !api?.accessToken ||
+ !jellyfinServerId
+ ) {
+ return [];
+ }
+
+ const streamystatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const response = await streamystatsApi.getPromotedWatchlists({
+ jellyfinServerId,
+ includePreview: false,
+ });
+
+ return response.data || [];
+ },
+ enabled:
+ enabled &&
+ streamyStatsEnabled &&
+ Boolean(api?.accessToken) &&
+ Boolean(jellyfinServerId) &&
+ Boolean(user?.Id),
+ staleTime: 5 * 60 * 1000,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ });
+
+ if (!streamyStatsEnabled) return null;
+ if (isError) return null;
+ if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
+
+ if (isLoading) {
+ return (
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Loading...
+
+
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+ {watchlists?.map((watchlist) => (
+
+ ))}
+
+ );
+};
diff --git a/components/home/StreamystatsRecommendations.tsx b/components/home/StreamystatsRecommendations.tsx
new file mode 100644
index 00000000..fb26708c
--- /dev/null
+++ b/components/home/StreamystatsRecommendations.tsx
@@ -0,0 +1,197 @@
+import type {
+ BaseItemDto,
+ PublicSystemInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
+import { useMemo } from "react";
+import { ScrollView, View, type ViewProps } from "react-native";
+
+import { SectionHeader } from "@/components/common/SectionHeader";
+import { Text } from "@/components/common/Text";
+import MoviePoster from "@/components/posters/MoviePoster";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { createStreamystatsApi } from "@/utils/streamystats/api";
+import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
+import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import { ItemCardText } from "../ItemCardText";
+import SeriesPoster from "../posters/SeriesPoster";
+
+const ITEM_WIDTH = 120; // w-28 (112px) + mr-2 (8px)
+
+interface Props extends ViewProps {
+ title: string;
+ type: "Movie" | "Series";
+ limit?: number;
+ enabled?: boolean;
+}
+
+export const StreamystatsRecommendations: React.FC = ({
+ title,
+ type,
+ limit = 20,
+ enabled = true,
+ ...props
+}) => {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+
+ const streamyStatsEnabled = useMemo(() => {
+ return Boolean(settings?.streamyStatsServerUrl);
+ }, [settings?.streamyStatsServerUrl]);
+
+ // Fetch server info to get the Jellyfin server ID
+ const { data: serverInfo } = useQuery({
+ queryKey: ["jellyfin", "serverInfo"],
+ queryFn: async (): Promise => {
+ if (!api) return null;
+ const response = await getSystemApi(api).getPublicSystemInfo();
+ return response.data;
+ },
+ enabled: enabled && Boolean(api) && streamyStatsEnabled,
+ staleTime: 60 * 60 * 1000, // 1 hour - server info rarely changes
+ });
+
+ const jellyfinServerId = serverInfo?.Id;
+
+ const {
+ data: recommendationIds,
+ isLoading: isLoadingRecommendations,
+ isError: isRecommendationsError,
+ } = useQuery({
+ queryKey: [
+ "streamystats",
+ "recommendations",
+ type,
+ jellyfinServerId,
+ settings?.streamyStatsServerUrl,
+ ],
+ queryFn: async (): Promise => {
+ if (
+ !settings?.streamyStatsServerUrl ||
+ !api?.accessToken ||
+ !jellyfinServerId
+ ) {
+ return [];
+ }
+
+ const streamyStatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const response = await streamyStatsApi.getRecommendationIds(
+ jellyfinServerId,
+ type,
+ limit,
+ );
+
+ const data = response as StreamystatsRecommendationsIdsResponse;
+
+ if (type === "Movie") {
+ return data.data.movies || [];
+ }
+ return data.data.series || [];
+ },
+ enabled:
+ enabled &&
+ streamyStatsEnabled &&
+ Boolean(api?.accessToken) &&
+ Boolean(jellyfinServerId) &&
+ Boolean(user?.Id),
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ });
+
+ const {
+ data: items,
+ isLoading: isLoadingItems,
+ isError: isItemsError,
+ } = useQuery({
+ queryKey: [
+ "streamystats",
+ "recommendations",
+ "items",
+ type,
+ recommendationIds,
+ ],
+ queryFn: async (): Promise => {
+ if (!api || !user?.Id || !recommendationIds?.length) {
+ return [];
+ }
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ ids: recommendationIds,
+ fields: ["PrimaryImageAspectRatio", "Genres"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ });
+
+ return response.data.Items || [];
+ },
+ enabled:
+ Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
+ staleTime: 5 * 60 * 1000,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ });
+
+ const isLoading = isLoadingRecommendations || isLoadingItems;
+ const isError = isRecommendationsError || isItemsError;
+
+ const snapOffsets = useMemo(() => {
+ return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
+ }, [items]);
+
+ if (!streamyStatsEnabled) return null;
+ if (isError) return null;
+ if (!isLoading && (!items || items.length === 0)) return null;
+
+ return (
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Loading title...
+
+
+
+ ))}
+
+ ) : (
+
+
+ {items?.map((item) => (
+
+ {item.Type === "Movie" && }
+ {item.Type === "Series" && }
+
+
+ ))}
+
+
+ )}
+
+ );
+};
diff --git a/components/item/ItemPeopleSections.tsx b/components/item/ItemPeopleSections.tsx
new file mode 100644
index 00000000..0b16271f
--- /dev/null
+++ b/components/item/ItemPeopleSections.tsx
@@ -0,0 +1,77 @@
+import type {
+ BaseItemDto,
+ BaseItemPerson,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { InteractionManager, View, type ViewProps } from "react-native";
+import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
+import { CastAndCrew } from "@/components/series/CastAndCrew";
+import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+
+interface Props extends ViewProps {
+ item: BaseItemDto;
+}
+
+export const ItemPeopleSections: React.FC = ({ item, ...props }) => {
+ const isOffline = useOfflineMode();
+ const [enabled, setEnabled] = useState(false);
+
+ useEffect(() => {
+ if (isOffline) return;
+ const task = InteractionManager.runAfterInteractions(() =>
+ setEnabled(true),
+ );
+ return () => task.cancel();
+ }, [isOffline]);
+
+ const { data, isLoading } = useItemPeopleQuery(
+ item.Id,
+ enabled && !isOffline,
+ );
+
+ const people = useMemo(() => (Array.isArray(data) ? data : []), [data]);
+
+ const itemWithPeople = useMemo(() => {
+ return { ...item, People: people } as BaseItemDto;
+ }, [item, people]);
+
+ const topPeople = useMemo(() => people.slice(0, 3), [people]);
+
+ const renderActorSection = useCallback(
+ (person: BaseItemPerson, idx: number, total: number) => {
+ if (!person.Id) return null;
+
+ const spacingClassName = idx === total - 1 ? undefined : "mb-2";
+
+ return (
+
+ );
+ },
+ [item],
+ );
+
+ if (isOffline || !enabled) return null;
+
+ const shouldSpaceCastAndCrew = topPeople.length > 0;
+
+ return (
+
+
+ {topPeople.map((person, idx) =>
+ renderActorSection(person, idx, topPeople.length),
+ )}
+
+ );
+};
diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx
index efb29013..09de500d 100644
--- a/components/library/LibraryItemCard.tsx
+++ b/components/library/LibraryItemCard.tsx
@@ -63,6 +63,10 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
_itemType = "Series";
} else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet";
+ } else if (library.CollectionType === "homevideos") {
+ _itemType = "Video";
+ } else if (library.CollectionType === "musicvideos") {
+ _itemType = "MusicVideo";
}
return _itemType;
diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx
index 7ce33986..fed62315 100644
--- a/components/list/ListItem.tsx
+++ b/components/list/ListItem.tsx
@@ -6,6 +6,7 @@ import { Text } from "../common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
subtitle?: string | null | undefined;
+ subtitleColor?: "default" | "red";
value?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
@@ -14,6 +15,7 @@ interface Props extends ViewProps {
textColor?: "default" | "blue" | "red";
onPress?: () => void;
disabled?: boolean;
+ disabledByAdmin?: boolean;
}
export const ListItem: React.FC> = ({
@@ -27,21 +29,23 @@ export const ListItem: React.FC> = ({
textColor = "default",
onPress,
disabled = false,
+ disabledByAdmin = false,
...viewProps
}) => {
+ const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
+ const isDisabled = disabled || disabledByAdmin;
if (onPress)
return (
> = ({
);
return (
> = ({
const ListItemContent = ({
title,
subtitle,
+ subtitleColor,
textColor,
icon,
value,
@@ -107,7 +111,7 @@ const ListItemContent = ({
{subtitle && (
{subtitle}
diff --git a/components/music/AnimatedEqualizer.tsx b/components/music/AnimatedEqualizer.tsx
new file mode 100644
index 00000000..35092931
--- /dev/null
+++ b/components/music/AnimatedEqualizer.tsx
@@ -0,0 +1,98 @@
+import React, { useEffect } from "react";
+import { View } from "react-native";
+import Animated, {
+ Easing,
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withRepeat,
+ withTiming,
+} from "react-native-reanimated";
+
+interface Props {
+ color?: string;
+ barWidth?: number;
+ barCount?: number;
+ height?: number;
+ gap?: number;
+}
+
+const MIN_SCALE = 0.35;
+const MAX_SCALE = 1;
+const DURATIONS = [800, 650, 750];
+const DELAYS = [0, 200, 100];
+
+const Bar: React.FC<{
+ color: string;
+ barWidth: number;
+ height: number;
+ duration: number;
+ delay: number;
+}> = ({ color, barWidth, height, duration, delay }) => {
+ const progress = useSharedValue(0);
+
+ useEffect(() => {
+ progress.value = withDelay(
+ delay,
+ withRepeat(
+ withTiming(1, { duration, easing: Easing.inOut(Easing.ease) }),
+ -1,
+ true,
+ ),
+ );
+ }, []);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ scaleY: interpolate(progress.value, [0, 1], [MIN_SCALE, MAX_SCALE]),
+ },
+ ],
+ }));
+
+ return (
+
+ );
+};
+
+export const AnimatedEqualizer: React.FC = ({
+ color = "#9334E9",
+ barWidth = 3,
+ barCount = 3,
+ height = 12,
+ gap = 2,
+}) => {
+ return (
+
+ {Array.from({ length: barCount }).map((_, index) => (
+
+ ))}
+
+ );
+};
diff --git a/components/music/CreatePlaylistModal.tsx b/components/music/CreatePlaylistModal.tsx
new file mode 100644
index 00000000..fea1421f
--- /dev/null
+++ b/components/music/CreatePlaylistModal.tsx
@@ -0,0 +1,157 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetTextInput,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import { ActivityIndicator, Keyboard } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import { useCreatePlaylist } from "@/hooks/usePlaylistMutations";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ onPlaylistCreated?: (playlistId: string) => void;
+ initialTrackId?: string;
+}
+
+export const CreatePlaylistModal: React.FC = ({
+ open,
+ setOpen,
+ onPlaylistCreated,
+ initialTrackId,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+ const createPlaylist = useCreatePlaylist();
+
+ const [name, setName] = useState("");
+ const snapPoints = useMemo(() => ["40%"], []);
+
+ useEffect(() => {
+ if (open) {
+ setName("");
+ bottomSheetModalRef.current?.present();
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ Keyboard.dismiss();
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleCreate = useCallback(async () => {
+ if (!name.trim()) return;
+
+ const result = await createPlaylist.mutateAsync({
+ name: name.trim(),
+ trackIds: initialTrackId ? [initialTrackId] : undefined,
+ });
+
+ if (result) {
+ onPlaylistCreated?.(result);
+ }
+ setOpen(false);
+ }, [name, createPlaylist, initialTrackId, onPlaylistCreated, setOpen]);
+
+ const isValid = name.trim().length > 0;
+
+ return (
+
+
+
+ {t("music.playlists.create_playlist")}
+
+
+
+ {t("music.playlists.playlist_name")}
+
+
+
+
+
+
+ );
+};
diff --git a/components/music/MiniPlayerBar.tsx b/components/music/MiniPlayerBar.tsx
new file mode 100644
index 00000000..41f5a553
--- /dev/null
+++ b/components/music/MiniPlayerBar.tsx
@@ -0,0 +1,359 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import {
+ ActivityIndicator,
+ Platform,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import { GlassEffectView } from "react-native-glass-effect-view";
+import Animated, {
+ Easing,
+ Extrapolation,
+ interpolate,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+
+const HORIZONTAL_MARGIN = Platform.OS === "android" ? 12 : 20;
+const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52;
+const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50;
+
+// Gesture thresholds
+const VELOCITY_THRESHOLD = 1000;
+
+// Logarithmic slowdown - never stops, just gets progressively slower
+const rubberBand = (distance: number, scale: number = 8): number => {
+ "worklet";
+ const absDistance = Math.abs(distance);
+ const sign = distance < 0 ? -1 : 1;
+ // Logarithmic: keeps growing but slower and slower
+ return sign * scale * Math.log(1 + absDistance / scale);
+};
+
+export const MiniPlayerBar: React.FC = () => {
+ const [api] = useAtom(apiAtom);
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const {
+ currentTrack,
+ isPlaying,
+ isLoading,
+ progress,
+ duration,
+ togglePlayPause,
+ next,
+ stop,
+ } = useMusicPlayer();
+
+ // Gesture state
+ const translateY = useSharedValue(0);
+
+ const imageUrl = useMemo(() => {
+ if (!api || !currentTrack) return null;
+ const albumId = currentTrack.AlbumId || currentTrack.ParentId;
+ if (albumId) {
+ return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
+ }
+ return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
+ }, [api, currentTrack]);
+
+ const _progressPercentage = useMemo(() => {
+ if (!duration || duration === 0) return 0;
+ return (progress / duration) * 100;
+ }, [progress, duration]);
+
+ const handlePress = useCallback(() => {
+ router.push("/(auth)/now-playing");
+ }, [router]);
+
+ const handlePlayPause = useCallback(
+ (e: any) => {
+ e.stopPropagation();
+ togglePlayPause();
+ },
+ [togglePlayPause],
+ );
+
+ const handleNext = useCallback(
+ (e: any) => {
+ e.stopPropagation();
+ next();
+ },
+ [next],
+ );
+
+ const handleDismiss = useCallback(() => {
+ stop();
+ }, [stop]);
+
+ // Pan gesture for swipe up (open modal) and swipe down (dismiss)
+ const panGesture = Gesture.Pan()
+ .activeOffsetY([-15, 15])
+ .onUpdate((event) => {
+ // Logarithmic slowdown - keeps moving but progressively slower
+ translateY.value = rubberBand(event.translationY, 6);
+ })
+ .onEnd((event) => {
+ const velocity = event.velocityY;
+ const currentPosition = translateY.value;
+
+ // Swipe up - open modal (check position OR velocity)
+ if (currentPosition < -16 || velocity < -VELOCITY_THRESHOLD) {
+ // Slow return animation - won't jank with navigation
+ translateY.value = withTiming(0, {
+ duration: 600,
+ easing: Easing.out(Easing.cubic),
+ });
+ runOnJS(handlePress)();
+ return;
+ }
+ // Swipe down - stop playback and dismiss (check position OR velocity)
+ if (currentPosition > 16 || velocity > VELOCITY_THRESHOLD) {
+ // No need to reset - component will unmount
+ runOnJS(handleDismiss)();
+ return;
+ }
+
+ // Only animate back if no action was triggered
+ translateY.value = withTiming(0, {
+ duration: 200,
+ easing: Easing.out(Easing.cubic),
+ });
+ });
+
+ // Animated styles for the container
+ const animatedContainerStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: translateY.value }],
+ }));
+
+ // Animated styles for the inner bar
+ const animatedBarStyle = useAnimatedStyle(() => ({
+ height: interpolate(
+ translateY.value,
+ [-50, 0, 50],
+ [BAR_HEIGHT + 12, BAR_HEIGHT, BAR_HEIGHT],
+ Extrapolation.EXTEND,
+ ),
+ opacity: interpolate(
+ translateY.value,
+ [0, 30],
+ [1, 0.6],
+ Extrapolation.CLAMP,
+ ),
+ }));
+
+ if (!currentTrack) return null;
+
+ const content = (
+ <>
+ {/* Tappable area: Album art + Track info */}
+
+ {/* Album art */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Track info */}
+
+
+ {currentTrack.Name}
+
+
+ {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
+
+
+
+
+ {/* Controls */}
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ {/* Progress bar at bottom */}
+ {/*
+
+ */}
+ >
+ );
+
+ return (
+
+
+
+ {Platform.OS === "ios" ? (
+
+
+ {content}
+
+
+ ) : (
+ {content}
+ )}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ left: HORIZONTAL_MARGIN,
+ right: HORIZONTAL_MARGIN,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 8,
+ },
+ touchable: {
+ borderRadius: 50,
+ overflow: "hidden",
+ },
+ blurContainer: {
+ flex: 1,
+ },
+ androidContainer: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 10,
+ paddingVertical: 8,
+ backgroundColor: "rgba(28, 28, 30, 0.97)",
+ borderRadius: 14,
+ borderWidth: 0.5,
+ borderColor: "rgba(255, 255, 255, 0.1)",
+ },
+ tappableArea: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ albumArt: {
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ overflow: "hidden",
+ backgroundColor: "#333",
+ },
+ albumImage: {
+ width: "100%",
+ height: "100%",
+ },
+ albumPlaceholder: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "#2a2a2a",
+ },
+ trackInfo: {
+ flex: 1,
+ marginLeft: 12,
+ marginRight: 8,
+ justifyContent: "center",
+ },
+ trackTitle: {
+ color: "white",
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ artistName: {
+ color: "rgba(255, 255, 255, 0.6)",
+ fontSize: 12,
+ },
+ controls: {
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ controlButton: {
+ padding: 8,
+ },
+ loader: {
+ marginHorizontal: 16,
+ },
+ progressContainer: {
+ position: "absolute",
+ bottom: 0,
+ left: 10,
+ right: 10,
+ height: 3,
+ backgroundColor: "rgba(255, 255, 255, 0.15)",
+ borderRadius: 1.5,
+ },
+ progressFill: {
+ height: "100%",
+ backgroundColor: "white",
+ borderRadius: 1.5,
+ },
+});
diff --git a/components/music/MusicAlbumCard.tsx b/components/music/MusicAlbumCard.tsx
new file mode 100644
index 00000000..11e78610
--- /dev/null
+++ b/components/music/MusicAlbumCard.tsx
@@ -0,0 +1,65 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface Props {
+ album: BaseItemDto;
+ width?: number;
+}
+
+export const MusicAlbumCard: React.FC = ({ album, width = 130 }) => {
+ const [api] = useAtom(apiAtom);
+ const router = useRouter();
+
+ const imageUrl = useMemo(
+ () => getPrimaryImageUrl({ api, item: album }),
+ [api, album],
+ );
+
+ const handlePress = useCallback(() => {
+ router.push(`/music/album/${album.Id}`);
+ }, [router, album.Id]);
+
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ ๐ต
+
+ )}
+
+
+ {album.Name}
+
+
+ {album.AlbumArtist || album.Artists?.join(", ")}
+
+
+ );
+};
diff --git a/components/music/MusicAlbumRowCard.tsx b/components/music/MusicAlbumRowCard.tsx
new file mode 100644
index 00000000..e794a793
--- /dev/null
+++ b/components/music/MusicAlbumRowCard.tsx
@@ -0,0 +1,67 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface Props {
+ album: BaseItemDto;
+}
+
+const IMAGE_SIZE = 56;
+
+export const MusicAlbumRowCard: React.FC = ({ album }) => {
+ const [api] = useAtom(apiAtom);
+ const router = useRouter();
+
+ const imageUrl = useMemo(
+ () => getPrimaryImageUrl({ api, item: album }),
+ [api, album],
+ );
+
+ const handlePress = useCallback(() => {
+ router.push(`/music/album/${album.Id}`);
+ }, [router, album.Id]);
+
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ ๐ต
+
+ )}
+
+
+
+ {album.Name}
+
+
+ {album.AlbumArtist || album.Artists?.join(", ")}
+
+
+
+ );
+};
diff --git a/components/music/MusicArtistCard.tsx b/components/music/MusicArtistCard.tsx
new file mode 100644
index 00000000..a9bfc61b
--- /dev/null
+++ b/components/music/MusicArtistCard.tsx
@@ -0,0 +1,66 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface Props {
+ artist: BaseItemDto;
+ size?: number;
+}
+
+const IMAGE_SIZE = 48;
+
+export const MusicArtistCard: React.FC = ({ artist }) => {
+ const [api] = useAtom(apiAtom);
+ const router = useRouter();
+
+ const imageUrl = useMemo(
+ () => getPrimaryImageUrl({ api, item: artist }),
+ [api, artist],
+ );
+
+ const handlePress = useCallback(() => {
+ router.push(`/music/artist/${artist.Id}`);
+ }, [router, artist.Id]);
+
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ ๐ค
+
+ )}
+
+
+ {artist.Name}
+
+
+ );
+};
diff --git a/components/music/MusicPlaybackEngine.tsx b/components/music/MusicPlaybackEngine.tsx
new file mode 100644
index 00000000..ae1b07cd
--- /dev/null
+++ b/components/music/MusicPlaybackEngine.tsx
@@ -0,0 +1,222 @@
+import { useEffect, useRef } from "react";
+import TrackPlayer, {
+ Event,
+ type PlaybackActiveTrackChangedEvent,
+ State,
+ useActiveTrack,
+ usePlaybackState,
+ useProgress,
+} from "react-native-track-player";
+import {
+ audioStorageEvents,
+ deleteTrack,
+ getLocalPath,
+} from "@/providers/AudioStorage";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+
+export const MusicPlaybackEngine: React.FC = () => {
+ const { position, duration } = useProgress(1000);
+ const playbackState = usePlaybackState();
+ const activeTrack = useActiveTrack();
+ const {
+ setProgress,
+ setDuration,
+ setIsPlaying,
+ reportProgress,
+ onTrackEnd,
+ syncFromTrackPlayer,
+ triggerLookahead,
+ } = useMusicPlayer();
+
+ const lastReportedProgressRef = useRef(0);
+
+ // Sync progress from TrackPlayer to our state
+ useEffect(() => {
+ if (position > 0) {
+ setProgress(position);
+ }
+ }, [position, setProgress]);
+
+ // Sync duration from TrackPlayer to our state
+ useEffect(() => {
+ if (duration > 0) {
+ setDuration(duration);
+ }
+ }, [duration, setDuration]);
+
+ // Sync playback state from TrackPlayer to our state
+ useEffect(() => {
+ const isPlaying = playbackState.state === State.Playing;
+ setIsPlaying(isPlaying);
+ }, [playbackState.state, setIsPlaying]);
+
+ // Sync active track changes
+ useEffect(() => {
+ if (activeTrack) {
+ syncFromTrackPlayer();
+ }
+ }, [activeTrack?.id, syncFromTrackPlayer]);
+
+ // Report progress every ~10 seconds
+ useEffect(() => {
+ if (
+ Math.floor(position) - Math.floor(lastReportedProgressRef.current) >=
+ 10
+ ) {
+ lastReportedProgressRef.current = position;
+ reportProgress();
+ }
+ }, [position, reportProgress]);
+
+ // Listen for track changes (native -> JS)
+ // This triggers look-ahead caching, checks for cached versions, and handles track end
+ useEffect(() => {
+ const subscription =
+ TrackPlayer.addEventListener(
+ Event.PlaybackActiveTrackChanged,
+ async (event) => {
+ // Trigger look-ahead caching when a new track starts playing
+ if (event.track) {
+ triggerLookahead();
+
+ // Check if there's a cached version we should use instead
+ const trackId = event.track.id;
+ const currentUrl = event.track.url as string;
+
+ // Only check if currently using a remote URL
+ if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
+ const cachedPath = getLocalPath(trackId);
+ if (cachedPath) {
+ console.log(
+ `[AudioCache] Switching to cached version for ${trackId}`,
+ );
+ try {
+ // Load the cached version, preserving position if any
+ const currentIndex = await TrackPlayer.getActiveTrackIndex();
+ if (currentIndex !== undefined && currentIndex >= 0) {
+ const queue = await TrackPlayer.getQueue();
+ const track = queue[currentIndex];
+ // Remove and re-add with cached URL
+ await TrackPlayer.remove(currentIndex);
+ await TrackPlayer.add(
+ { ...track, url: cachedPath },
+ currentIndex,
+ );
+ await TrackPlayer.skip(currentIndex);
+ await TrackPlayer.play();
+ }
+ } catch (error) {
+ console.warn(
+ "[AudioCache] Failed to switch to cached version:",
+ error,
+ );
+ }
+ }
+ }
+ }
+
+ // If there's no next track and the previous track ended, call onTrackEnd
+ if (event.lastTrack && !event.track) {
+ onTrackEnd();
+ }
+ },
+ );
+
+ return () => subscription.remove();
+ }, [onTrackEnd, triggerLookahead]);
+
+ // Listen for audio cache download completion and update queue URLs
+ useEffect(() => {
+ const onComplete = async ({
+ itemId,
+ localPath,
+ }: {
+ itemId: string;
+ localPath: string;
+ }) => {
+ console.log(`[AudioCache] Track ${itemId} cached successfully`);
+
+ try {
+ const queue = await TrackPlayer.getQueue();
+ const currentIndex = await TrackPlayer.getActiveTrackIndex();
+
+ // Find the track in the queue
+ const trackIndex = queue.findIndex((t) => t.id === itemId);
+
+ // Only update if track is in queue and not currently playing
+ if (trackIndex >= 0 && trackIndex !== currentIndex) {
+ const track = queue[trackIndex];
+ const localUrl = localPath.startsWith("file://")
+ ? localPath
+ : `file://${localPath}`;
+
+ // Skip if already using local URL
+ if (track.url === localUrl) return;
+
+ console.log(
+ `[AudioCache] Updating queue track ${trackIndex} to use cached file`,
+ );
+
+ // Remove old track and insert updated one at same position
+ await TrackPlayer.remove(trackIndex);
+ await TrackPlayer.add({ ...track, url: localUrl }, trackIndex);
+ }
+ } catch (error) {
+ console.warn("[AudioCache] Failed to update queue:", error);
+ }
+ };
+
+ audioStorageEvents.on("complete", onComplete);
+ return () => {
+ audioStorageEvents.off("complete", onComplete);
+ };
+ }, []);
+
+ // Listen for playback errors (corrupted cache files)
+ useEffect(() => {
+ const subscription = TrackPlayer.addEventListener(
+ Event.PlaybackError,
+ async (event) => {
+ const activeTrack = await TrackPlayer.getActiveTrack();
+ if (!activeTrack?.url) return;
+
+ // Only handle local file errors
+ const url = activeTrack.url as string;
+ if (!url.startsWith("file://")) return;
+
+ console.warn(
+ `[MusicPlayer] Playback error for cached file: ${activeTrack.id}`,
+ event,
+ );
+
+ // Delete corrupted cache file
+ if (activeTrack.id) {
+ try {
+ await deleteTrack(activeTrack.id);
+ console.log(
+ `[MusicPlayer] Deleted corrupted cache file: ${activeTrack.id}`,
+ );
+ } catch (error) {
+ console.warn(
+ "[MusicPlayer] Failed to delete corrupted file:",
+ error,
+ );
+ }
+ }
+
+ // Skip to next track
+ try {
+ await TrackPlayer.skipToNext();
+ } catch {
+ // No next track available, stop playback
+ await TrackPlayer.stop();
+ }
+ },
+ );
+
+ return () => subscription.remove();
+ }, []);
+
+ // No visual component needed - TrackPlayer is headless
+ return null;
+};
diff --git a/components/music/MusicPlaylistCard.tsx b/components/music/MusicPlaylistCard.tsx
new file mode 100644
index 00000000..50671715
--- /dev/null
+++ b/components/music/MusicPlaylistCard.tsx
@@ -0,0 +1,117 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { getLocalPath } from "@/providers/AudioStorage";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface Props {
+ playlist: BaseItemDto;
+ width?: number;
+}
+
+const IMAGE_SIZE = 56;
+
+export const MusicPlaylistCard: React.FC = ({ playlist }) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const router = useRouter();
+
+ const imageUrl = useMemo(
+ () => getPrimaryImageUrl({ api, item: playlist }),
+ [api, playlist],
+ );
+
+ // Fetch playlist tracks to check download status
+ const { data: tracks } = useQuery({
+ queryKey: ["playlist-tracks-status", playlist.Id, user?.Id],
+ queryFn: async () => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: playlist.Id,
+ fields: ["MediaSources"],
+ });
+ return response.data.Items || [];
+ },
+ enabled: !!api && !!user?.Id && !!playlist.Id,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ });
+
+ // Calculate download status
+ const downloadStatus = useMemo(() => {
+ if (!tracks || tracks.length === 0) {
+ return { downloaded: 0, total: playlist.ChildCount || 0 };
+ }
+ const downloaded = tracks.filter(
+ (track) => !!getLocalPath(track.Id),
+ ).length;
+ return { downloaded, total: tracks.length };
+ }, [tracks, playlist.ChildCount]);
+
+ const allDownloaded =
+ downloadStatus.total > 0 &&
+ downloadStatus.downloaded === downloadStatus.total;
+ const hasDownloads = downloadStatus.downloaded > 0;
+
+ const handlePress = useCallback(() => {
+ router.push(`/music/playlist/${playlist.Id}`);
+ }, [router, playlist.Id]);
+
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ ๐ถ
+
+ )}
+
+
+
+ {playlist.Name}
+
+
+ {playlist.ChildCount} tracks
+
+
+ {/* Download status indicator */}
+ {allDownloaded ? (
+
+ ) : hasDownloads ? (
+
+ {downloadStatus.downloaded}/{downloadStatus.total}
+
+ ) : null}
+
+ );
+};
diff --git a/components/music/MusicTrackItem.tsx b/components/music/MusicTrackItem.tsx
new file mode 100644
index 00000000..a3f80efc
--- /dev/null
+++ b/components/music/MusicTrackItem.tsx
@@ -0,0 +1,226 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { ActivityIndicator, TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer";
+import { useHaptic } from "@/hooks/useHaptic";
+import { useNetworkStatus } from "@/hooks/useNetworkStatus";
+import {
+ audioStorageEvents,
+ getLocalPath,
+ isCached,
+ isPermanentDownloading,
+ isPermanentlyDownloaded,
+} from "@/providers/AudioStorage";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { formatDuration } from "@/utils/time";
+
+interface Props {
+ track: BaseItemDto;
+ index?: number;
+ queue?: BaseItemDto[];
+ showArtwork?: boolean;
+ onOptionsPress?: (track: BaseItemDto) => void;
+}
+
+export const MusicTrackItem: React.FC = ({
+ track,
+ index: _index,
+ queue,
+ showArtwork = true,
+ onOptionsPress,
+}) => {
+ const [api] = useAtom(apiAtom);
+ const { playTrack, currentTrack, isPlaying, loadingTrackId } =
+ useMusicPlayer();
+ const { isConnected, serverConnected } = useNetworkStatus();
+ const haptic = useHaptic("light");
+
+ const imageUrl = useMemo(() => {
+ const albumId = track.AlbumId || track.ParentId;
+ if (albumId) {
+ return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
+ }
+ return getPrimaryImageUrl({ api, item: track });
+ }, [api, track]);
+
+ const isCurrentTrack = currentTrack?.Id === track.Id;
+ const isTrackLoading = loadingTrackId === track.Id;
+
+ // Track download status with reactivity to completion events
+ const [downloadStatus, setDownloadStatus] = useState<
+ "none" | "downloading" | "downloaded" | "cached"
+ >(() => {
+ if (isPermanentlyDownloaded(track.Id)) return "downloaded";
+ if (isPermanentDownloading(track.Id)) return "downloading";
+ if (isCached(track.Id)) return "cached";
+ return "none";
+ });
+
+ // Listen for download completion/error events
+ useEffect(() => {
+ const onComplete = (event: { itemId: string; permanent: boolean }) => {
+ if (event.itemId === track.Id) {
+ setDownloadStatus(event.permanent ? "downloaded" : "cached");
+ }
+ };
+ const onError = (event: { itemId: string }) => {
+ if (event.itemId === track.Id) {
+ setDownloadStatus("none");
+ }
+ };
+
+ audioStorageEvents.on("complete", onComplete);
+ audioStorageEvents.on("error", onError);
+
+ return () => {
+ audioStorageEvents.off("complete", onComplete);
+ audioStorageEvents.off("error", onError);
+ };
+ }, [track.Id]);
+
+ // Re-check status when track changes (for list item recycling)
+ useEffect(() => {
+ if (isPermanentlyDownloaded(track.Id)) {
+ setDownloadStatus("downloaded");
+ } else if (isPermanentDownloading(track.Id)) {
+ setDownloadStatus("downloading");
+ } else if (isCached(track.Id)) {
+ setDownloadStatus("cached");
+ } else {
+ setDownloadStatus("none");
+ }
+ }, [track.Id]);
+
+ const _isDownloaded = downloadStatus === "downloaded";
+ // Check if available locally (either cached or permanently downloaded)
+ const isAvailableLocally = !!getLocalPath(track.Id);
+ // Consider offline if either no network connection OR server is unreachable
+ const isOffline = !isConnected || serverConnected === false;
+ const isUnavailableOffline = isOffline && !isAvailableLocally;
+
+ const duration = useMemo(() => {
+ if (!track.RunTimeTicks) return "";
+ return formatDuration(track.RunTimeTicks);
+ }, [track.RunTimeTicks]);
+
+ const handlePress = useCallback(() => {
+ if (isUnavailableOffline) return;
+ playTrack(track, queue);
+ }, [playTrack, track, queue, isUnavailableOffline]);
+
+ const handleLongPress = useCallback(() => {
+ onOptionsPress?.(track);
+ }, [onOptionsPress, track]);
+
+ const handleOptionsPress = useCallback(() => {
+ haptic();
+ onOptionsPress?.(track);
+ }, [haptic, onOptionsPress, track]);
+
+ return (
+
+ {/* Album artwork */}
+ {showArtwork && (
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+ {isTrackLoading && (
+
+
+
+ )}
+
+ )}
+
+ {/* Track info */}
+
+
+ {isCurrentTrack && isPlaying && }
+
+ {track.Name}
+
+
+
+ {track.Artists?.join(", ") || track.AlbumArtist}
+
+
+
+ {/* Download/cache status indicator */}
+ {downloadStatus === "downloading" && (
+
+ )}
+ {downloadStatus === "downloaded" && (
+
+ )}
+
+ {/* Duration */}
+ {duration}
+
+ {/* Options button */}
+ {onOptionsPress && (
+
+
+
+ )}
+
+ );
+};
diff --git a/components/music/PlaylistOptionsSheet.tsx b/components/music/PlaylistOptionsSheet.tsx
new file mode 100644
index 00000000..ab2b3a24
--- /dev/null
+++ b/components/music/PlaylistOptionsSheet.tsx
@@ -0,0 +1,136 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ playlist: BaseItemDto | null;
+}
+
+export const PlaylistOptionsSheet: React.FC = ({
+ open,
+ setOpen,
+ playlist,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+ const deletePlaylist = useDeletePlaylist();
+
+ const snapPoints = useMemo(() => ["25%"], []);
+
+ useEffect(() => {
+ if (open) bottomSheetModalRef.current?.present();
+ else bottomSheetModalRef.current?.dismiss();
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleDeletePlaylist = useCallback(() => {
+ if (!playlist?.Id) return;
+
+ Alert.alert(
+ t("music.playlists.delete_playlist"),
+ t("music.playlists.delete_confirm", { name: playlist.Name }),
+ [
+ {
+ text: t("common.cancel"),
+ style: "cancel",
+ },
+ {
+ text: t("common.delete"),
+ style: "destructive",
+ onPress: () => {
+ deletePlaylist.mutate(
+ { playlistId: playlist.Id! },
+ {
+ onSuccess: () => {
+ setOpen(false);
+ router.back();
+ },
+ },
+ );
+ },
+ },
+ ],
+ );
+ }, [playlist, deletePlaylist, setOpen, router, t]);
+
+ if (!playlist) return null;
+
+ return (
+
+
+
+
+
+
+ {t("music.playlists.delete_playlist")}
+
+
+
+
+
+ );
+};
+
+const _styles = StyleSheet.create({
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: "#404040",
+ },
+});
diff --git a/components/music/PlaylistPickerSheet.tsx b/components/music/PlaylistPickerSheet.tsx
new file mode 100644
index 00000000..6cd7dc1b
--- /dev/null
+++ b/components/music/PlaylistPickerSheet.tsx
@@ -0,0 +1,262 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetScrollView,
+} from "@gorhom/bottom-sheet";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Input } from "@/components/common/Input";
+import { Text } from "@/components/common/Text";
+import { useAddToPlaylist } from "@/hooks/usePlaylistMutations";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ trackToAdd: BaseItemDto | null;
+ onCreateNew: () => void;
+}
+
+export const PlaylistPickerSheet: React.FC = ({
+ open,
+ setOpen,
+ trackToAdd,
+ onCreateNew,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+ const addToPlaylist = useAddToPlaylist();
+
+ const [search, setSearch] = useState("");
+ const snapPoints = useMemo(() => ["75%"], []);
+
+ // Fetch all playlists
+ const { data: playlists, isLoading } = useQuery({
+ queryKey: ["music-playlists-picker", user?.Id],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ includeItemTypes: ["Playlist"],
+ sortBy: ["SortName"],
+ sortOrder: ["Ascending"],
+ recursive: true,
+ mediaTypes: ["Audio"],
+ });
+
+ return response.data.Items || [];
+ },
+ enabled: Boolean(api && user?.Id && open),
+ });
+
+ const filteredPlaylists = useMemo(() => {
+ if (!playlists) return [];
+ if (!search) return playlists;
+ return playlists.filter((playlist) =>
+ playlist.Name?.toLowerCase().includes(search.toLowerCase()),
+ );
+ }, [playlists, search]);
+
+ const showSearch = (playlists?.length || 0) > 10;
+
+ useEffect(() => {
+ if (open) {
+ setSearch("");
+ bottomSheetModalRef.current?.present();
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleSelectPlaylist = useCallback(
+ async (playlist: BaseItemDto) => {
+ if (!trackToAdd?.Id || !playlist.Id) return;
+
+ await addToPlaylist.mutateAsync({
+ playlistId: playlist.Id,
+ trackIds: [trackToAdd.Id],
+ playlistName: playlist.Name || undefined,
+ });
+
+ setOpen(false);
+ },
+ [trackToAdd, addToPlaylist, setOpen],
+ );
+
+ const handleCreateNew = useCallback(() => {
+ setOpen(false);
+ setTimeout(() => {
+ onCreateNew();
+ }, 300);
+ }, [onCreateNew, setOpen]);
+
+ const getPlaylistImageUrl = useCallback(
+ (playlist: BaseItemDto) => {
+ if (!api) return null;
+ return `${api.basePath}/Items/${playlist.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
+ },
+ [api],
+ );
+
+ return (
+
+
+
+ {t("music.track_options.add_to_playlist")}
+
+ {trackToAdd?.Name}
+
+ {showSearch && (
+
+ )}
+
+ {/* Create New Playlist Button */}
+
+
+
+
+
+ {t("music.playlists.create_new")}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : filteredPlaylists.length === 0 ? (
+
+
+ {search ? t("search.no_results") : t("music.no_playlists")}
+
+
+ ) : (
+
+ {filteredPlaylists.map((playlist, index) => (
+
+ handleSelectPlaylist(playlist)}
+ className='flex-row items-center px-4 py-3'
+ disabled={addToPlaylist.isPending}
+ >
+
+
+
+
+
+ {playlist.Name}
+
+
+ {playlist.ChildCount} {t("music.tabs.tracks")}
+
+
+ {addToPlaylist.isPending && (
+
+ )}
+
+ {index < filteredPlaylists.length - 1 && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: "#404040",
+ },
+});
diff --git a/components/music/PlaylistSortSheet.tsx b/components/music/PlaylistSortSheet.tsx
new file mode 100644
index 00000000..07c8467c
--- /dev/null
+++ b/components/music/PlaylistSortSheet.tsx
@@ -0,0 +1,173 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { StyleSheet, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+
+export type PlaylistSortOption = "SortName" | "DateCreated";
+
+export type PlaylistSortOrder = "Ascending" | "Descending";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ sortBy: PlaylistSortOption;
+ sortOrder: PlaylistSortOrder;
+ onSortChange: (
+ sortBy: PlaylistSortOption,
+ sortOrder: PlaylistSortOrder,
+ ) => void;
+}
+
+const SORT_OPTIONS: { key: PlaylistSortOption; label: string; icon: string }[] =
+ [
+ { key: "SortName", label: "music.sort.alphabetical", icon: "text-outline" },
+ {
+ key: "DateCreated",
+ label: "music.sort.date_created",
+ icon: "time-outline",
+ },
+ ];
+
+export const PlaylistSortSheet: React.FC = ({
+ open,
+ setOpen,
+ sortBy,
+ sortOrder,
+ onSortChange,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+
+ const snapPoints = useMemo(() => ["40%"], []);
+
+ useEffect(() => {
+ if (open) bottomSheetModalRef.current?.present();
+ else bottomSheetModalRef.current?.dismiss();
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleSortSelect = useCallback(
+ (option: PlaylistSortOption) => {
+ // If selecting same option, toggle order; otherwise use sensible default
+ if (option === sortBy) {
+ onSortChange(
+ option,
+ sortOrder === "Ascending" ? "Descending" : "Ascending",
+ );
+ } else {
+ // Default order based on sort type
+ const defaultOrder: PlaylistSortOrder =
+ option === "SortName" ? "Ascending" : "Descending";
+ onSortChange(option, defaultOrder);
+ }
+ setOpen(false);
+ },
+ [sortBy, sortOrder, onSortChange, setOpen],
+ );
+
+ return (
+
+
+
+ {t("music.sort.title")}
+
+
+ {SORT_OPTIONS.map((option, index) => {
+ const isSelected = sortBy === option.key;
+ return (
+
+ {index > 0 && }
+ handleSortSelect(option.key)}
+ className='flex-row items-center px-4 py-3.5'
+ >
+
+
+ {t(option.label)}
+
+ {isSelected && (
+
+
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: "#404040",
+ },
+});
diff --git a/components/music/TrackOptionsSheet.tsx b/components/music/TrackOptionsSheet.tsx
new file mode 100644
index 00000000..b5de1379
--- /dev/null
+++ b/components/music/TrackOptionsSheet.tsx
@@ -0,0 +1,458 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { useFavorite } from "@/hooks/useFavorite";
+import {
+ audioStorageEvents,
+ deleteTrack,
+ downloadTrack,
+ isCached,
+ isPermanentDownloading,
+ isPermanentlyDownloaded,
+} from "@/providers/AudioStorage";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ track: BaseItemDto | null;
+ onAddToPlaylist: () => void;
+ playlistId?: string;
+ onRemoveFromPlaylist?: () => void;
+}
+
+export const TrackOptionsSheet: React.FC = ({
+ open,
+ setOpen,
+ track,
+ onAddToPlaylist,
+ playlistId,
+ onRemoveFromPlaylist,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const router = useRouter();
+ const { playNext, addToQueue } = useMusicPlayer();
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+ const [isDownloadingTrack, setIsDownloadingTrack] = useState(false);
+ // Counter to trigger re-evaluation of download status when storage changes
+ const [storageUpdateCounter, setStorageUpdateCounter] = useState(0);
+
+ // Listen for storage events to update download status
+ useEffect(() => {
+ const handleComplete = (event: { itemId: string }) => {
+ if (event.itemId === track?.Id) {
+ setStorageUpdateCounter((c) => c + 1);
+ }
+ };
+
+ audioStorageEvents.on("complete", handleComplete);
+ return () => {
+ audioStorageEvents.off("complete", handleComplete);
+ };
+ }, [track?.Id]);
+
+ // Force re-evaluation of cache status when track changes
+ useEffect(() => {
+ setStorageUpdateCounter((c) => c + 1);
+ }, [track?.Id]);
+
+ // Use a placeholder item for useFavorite when track is null
+ const { isFavorite, toggleFavorite } = useFavorite(
+ track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto),
+ );
+
+ // Check download status (storageUpdateCounter triggers re-evaluation when download completes)
+ const isAlreadyDownloaded = useMemo(
+ () => isPermanentlyDownloaded(track?.Id),
+ [track?.Id, storageUpdateCounter],
+ );
+ const isOnlyCached = useMemo(
+ () => isCached(track?.Id),
+ [track?.Id, storageUpdateCounter],
+ );
+ const isCurrentlyDownloading = useMemo(
+ () => isPermanentDownloading(track?.Id),
+ [track?.Id, storageUpdateCounter],
+ );
+
+ const imageUrl = useMemo(() => {
+ if (!track) return null;
+ const albumId = track.AlbumId || track.ParentId;
+ if (albumId) {
+ return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=200&maxWidth=200`;
+ }
+ return getPrimaryImageUrl({ api, item: track });
+ }, [api, track]);
+
+ useEffect(() => {
+ if (open) bottomSheetModalRef.current?.present();
+ else bottomSheetModalRef.current?.dismiss();
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handlePlayNext = useCallback(() => {
+ if (track) {
+ playNext(track);
+ setOpen(false);
+ }
+ }, [track, playNext, setOpen]);
+
+ const handleAddToQueue = useCallback(() => {
+ if (track) {
+ addToQueue(track);
+ setOpen(false);
+ }
+ }, [track, addToQueue, setOpen]);
+
+ const handleAddToPlaylist = useCallback(() => {
+ setOpen(false);
+ setTimeout(() => {
+ onAddToPlaylist();
+ }, 300);
+ }, [onAddToPlaylist, setOpen]);
+
+ const handleRemoveFromPlaylist = useCallback(() => {
+ if (onRemoveFromPlaylist) {
+ onRemoveFromPlaylist();
+ setOpen(false);
+ }
+ }, [onRemoveFromPlaylist, setOpen]);
+
+ const handleDownload = useCallback(async () => {
+ if (!track?.Id || !api || !user?.Id || isAlreadyDownloaded) return;
+
+ setIsDownloadingTrack(true);
+ try {
+ const result = await getAudioStreamUrl(api, user.Id, track.Id);
+ if (result?.url && !result.isTranscoding) {
+ await downloadTrack(track.Id, result.url, {
+ permanent: true,
+ container: result.mediaSource?.Container || undefined,
+ });
+ }
+ } catch {
+ // Silent fail
+ }
+ setIsDownloadingTrack(false);
+ setOpen(false);
+ }, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]);
+
+ const handleDelete = useCallback(async () => {
+ if (!track?.Id) return;
+ await deleteTrack(track.Id);
+ setStorageUpdateCounter((c) => c + 1);
+ setOpen(false);
+ }, [track?.Id, setOpen]);
+
+ const handleGoToArtist = useCallback(() => {
+ const artistId = track?.ArtistItems?.[0]?.Id;
+ if (artistId) {
+ setOpen(false);
+ router.push(`/music/artist/${artistId}`);
+ }
+ }, [track?.ArtistItems, router, setOpen]);
+
+ const handleGoToAlbum = useCallback(() => {
+ const albumId = track?.AlbumId || track?.ParentId;
+ if (albumId) {
+ setOpen(false);
+ router.push(`/music/album/${albumId}`);
+ }
+ }, [track?.AlbumId, track?.ParentId, router, setOpen]);
+
+ const handleToggleFavorite = useCallback(() => {
+ if (track) {
+ toggleFavorite();
+ setOpen(false);
+ }
+ }, [track, toggleFavorite, setOpen]);
+
+ // Check if navigation options are available
+ const hasArtist = !!track?.ArtistItems?.[0]?.Id;
+ const hasAlbum = !!(track?.AlbumId || track?.ParentId);
+
+ if (!track) return null;
+
+ return (
+
+
+ {/* Track Info Header */}
+
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {track.Name}
+
+
+ {track.Artists?.join(", ") || track.AlbumArtist}
+
+
+
+
+ {/* Playback Options */}
+
+
+
+
+ {t("music.track_options.play_next")}
+
+
+
+
+
+
+
+
+ {t("music.track_options.add_to_queue")}
+
+
+
+
+ {/* Library Options */}
+
+
+
+
+ {isFavorite
+ ? t("music.track_options.remove_from_favorites")
+ : t("music.track_options.add_to_favorites")}
+
+
+
+
+
+
+
+
+ {t("music.track_options.add_to_playlist")}
+
+
+
+ {playlistId && (
+ <>
+
+
+
+
+ {t("music.track_options.remove_from_playlist")}
+
+
+ >
+ )}
+
+
+
+
+ {isCurrentlyDownloading || isDownloadingTrack ? (
+
+ ) : (
+
+ )}
+
+ {isCurrentlyDownloading || isDownloadingTrack
+ ? t("music.track_options.downloading")
+ : isAlreadyDownloaded
+ ? t("music.track_options.downloaded")
+ : t("music.track_options.download")}
+
+
+
+ {isOnlyCached && !isAlreadyDownloaded && (
+ <>
+
+
+
+
+ {t("music.track_options.cached")}
+
+
+ >
+ )}
+
+ {(isAlreadyDownloaded || isOnlyCached) && (
+ <>
+
+
+
+
+ {isAlreadyDownloaded
+ ? t("music.track_options.delete_download")
+ : t("music.track_options.delete_cache")}
+
+
+ >
+ )}
+
+
+ {/* Navigation Options */}
+ {(hasArtist || hasAlbum) && (
+
+ {hasArtist && (
+ <>
+
+
+
+ {t("music.track_options.go_to_artist")}
+
+
+ {hasAlbum && }
+ >
+ )}
+
+ {hasAlbum && (
+
+
+
+ {t("music.track_options.go_to_album")}
+
+
+ )}
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: "#404040",
+ },
+});
diff --git a/components/music/index.ts b/components/music/index.ts
new file mode 100644
index 00000000..3d0767ad
--- /dev/null
+++ b/components/music/index.ts
@@ -0,0 +1,6 @@
+export * from "./MiniPlayerBar";
+export * from "./MusicAlbumCard";
+export * from "./MusicArtistCard";
+export * from "./MusicPlaybackEngine";
+export * from "./MusicPlaylistCard";
+export * from "./MusicTrackItem";
diff --git a/components/navigation/TabBarIcon.tsx b/components/navigation/TabBarIcon.tsx
deleted file mode 100644
index a28bba84..00000000
--- a/components/navigation/TabBarIcon.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
-
-import type { IconProps } from "@expo/vector-icons/build/createIconSet";
-import Ionicons from "@expo/vector-icons/Ionicons";
-import type { ComponentProps } from "react";
-
-export function TabBarIcon({
- style,
- ...rest
-}: IconProps["name"]>) {
- return ;
-}
diff --git a/components/posters/EpisodePoster.tsx b/components/posters/EpisodePoster.tsx
deleted file mode 100644
index af42989b..00000000
--- a/components/posters/EpisodePoster.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
-import { View } from "react-native";
-import { WatchedIndicator } from "@/components/WatchedIndicator";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-type MoviePosterProps = {
- item: BaseItemDto;
- showProgress?: boolean;
-};
-
-export const EpisodePoster: React.FC = ({
- 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 (
-
-
-
- {showProgress && progress > 0 && (
-
- )}
-
- );
-};
diff --git a/components/posters/ParentPoster.tsx b/components/posters/ParentPoster.tsx
deleted file mode 100644
index 47b62e4c..00000000
--- a/components/posters/ParentPoster.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-type PosterProps = {
- id?: string;
- showProgress?: boolean;
-};
-
-const ParentPoster: React.FC = ({ id }) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(
- () => `${api?.basePath}/Items/${id}/Images/Primary`,
- [id],
- );
-
- if (!url || !id)
- return (
-
- );
-
- return (
-
-
-
- );
-};
-
-export default ParentPoster;
diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/SeerrPoster.tsx
similarity index 84%
rename from components/posters/JellyseerrPoster.tsx
rename to components/posters/SeerrPoster.tsx
index d7b1fcdb..399ac386 100644
--- a/components/posters/JellyseerrPoster.tsx
+++ b/components/posters/SeerrPoster.tsx
@@ -7,15 +7,15 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
-import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
+import { TouchableSeerrRouter } from "@/components/common/SeerrItemRouter";
import { Text } from "@/components/common/Text";
import { Tag, Tags } from "@/components/GenreTags";
-import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
-import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
-import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
+import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
+import SeerrMediaIcon from "@/components/seerr/SeerrMediaIcon";
+import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
import { Colors } from "@/constants/Colors";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
-import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
+import { useSeerr } from "@/hooks/useSeerr";
+import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
@@ -34,13 +34,13 @@ interface Props extends ViewProps {
mediaRequest?: MediaRequest;
}
-const JellyseerrPoster: React.FC = ({
+const SeerrPoster: React.FC = ({
item,
horizontal,
showDownloadInfo,
mediaRequest,
}) => {
- const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
+ const { seerrApi, getTitle, getYear, getMediaType } = useSeerr();
const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0);
const { t } = useTranslation();
@@ -56,16 +56,13 @@ const JellyseerrPoster: React.FC = ({
const backdropSrc = useMemo(
() =>
- jellyseerrApi?.imageProxy(
- item?.backdropPath,
- "w1920_and_h800_multi_faces",
- ),
- [item, jellyseerrApi, horizontal],
+ seerrApi?.imageProxy(item?.backdropPath, "w1920_and_h800_multi_faces"),
+ [item, seerrApi, horizontal],
);
const posterSrc = useMemo(
- () => jellyseerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
- [item, jellyseerrApi, horizontal],
+ () => seerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
+ [item, seerrApi, horizontal],
);
const title = useMemo(() => getTitle(item), [item]);
@@ -75,7 +72,7 @@ const JellyseerrPoster: React.FC = ({
const size = useMemo(() => (horizontal ? "h-28" : "w-28"), [horizontal]);
const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]);
- const [canRequest] = useJellyseerrCanRequest(item);
+ const [canRequest] = useSeerrCanRequest(item);
const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]);
@@ -109,7 +106,7 @@ const JellyseerrPoster: React.FC = ({
second,
third,
fourth,
- t("home.settings.plugins.jellyseerr.plus_n_more", { n: rest.length }),
+ t("home.settings.plugins.seerr.plus_n_more", { n: rest.length }),
];
}
return seasons;
@@ -121,7 +118,7 @@ const JellyseerrPoster: React.FC = ({
}, [mediaRequest, is4k]);
return (
- = ({
)}
>
)}
-
-
@@ -201,8 +198,8 @@ const JellyseerrPoster: React.FC = ({
{releaseYear || ""}
-
+
);
};
-export default JellyseerrPoster;
+export default SeerrPoster;
diff --git a/components/search/DiscoverFilters.tsx b/components/search/DiscoverFilters.tsx
index 2e844c88..7a3a2a60 100644
--- a/components/search/DiscoverFilters.tsx
+++ b/components/search/DiscoverFilters.tsx
@@ -1,19 +1,19 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
-import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
+import { SeerrSearchSort } from "@/components/seerr/SeerrIndexPage";
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
- jellyseerrOrderBy: JellyseerrSearchSort;
- setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
- jellyseerrSortOrder: "asc" | "desc";
- setJellyseerrSortOrder: (value: "asc" | "desc") => void;
+ seerrOrderBy: SeerrSearchSort;
+ setSeerrOrderBy: (value: SeerrSearchSort) => void;
+ seerrSortOrder: "asc" | "desc";
+ setSeerrSortOrder: (value: "asc" | "desc") => void;
t: (key: string) => string;
}
-const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
+const sortOptions = Object.keys(SeerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
);
@@ -22,10 +22,10 @@ const orderOptions = ["asc", "desc"] as const;
export const DiscoverFilters: React.FC = ({
searchFilterId,
orderFilterId,
- jellyseerrOrderBy,
- setJellyseerrOrderBy,
- jellyseerrSortOrder,
- setJellyseerrSortOrder,
+ seerrOrderBy,
+ setSeerrOrderBy,
+ seerrSortOrder,
+ setSeerrSortOrder,
t,
}) => {
if (Platform.OS === "ios") {
@@ -52,16 +52,16 @@ export const DiscoverFilters: React.FC = ({
- t(`home.settings.plugins.jellyseerr.order_by.${item}`),
+ t(`home.settings.plugins.seerr.order_by.${item}`),
)}
variant='menu'
selectedIndex={sortOptions.indexOf(
- jellyseerrOrderBy as unknown as string,
+ seerrOrderBy as unknown as string,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
- setJellyseerrOrderBy(
- sortOptions[index] as unknown as JellyseerrSearchSort,
+ setSeerrOrderBy(
+ sortOptions[index] as unknown as SeerrSearchSort,
);
}}
/>
@@ -69,10 +69,10 @@ export const DiscoverFilters: React.FC = ({
label={t("library.filters.sort_order")}
options={orderOptions.map((item) => t(`library.filters.${item}`))}
variant='menu'
- selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
+ selectedIndex={orderOptions.indexOf(seerrSortOrder)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
- setJellyseerrSortOrder(orderOptions[index]);
+ setSeerrSortOrder(orderOptions[index]);
}}
/>
@@ -86,17 +86,15 @@ export const DiscoverFilters: React.FC = ({
- Object.keys(JellyseerrSearchSort).filter((v) =>
- Number.isNaN(Number(v)),
- )
+ Object.keys(SeerrSearchSort).filter((v) => Number.isNaN(Number(v)))
}
- set={(value) => setJellyseerrOrderBy(value[0])}
- values={[jellyseerrOrderBy]}
+ set={(value) => setSeerrOrderBy(value[0])}
+ values={[seerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
- t(`home.settings.plugins.jellyseerr.order_by.${item}`)
+ t(`home.settings.plugins.seerr.order_by.${item}`)
}
disableSearch={true}
/>
@@ -104,8 +102,8 @@ export const DiscoverFilters: React.FC = ({
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
- set={(value) => setJellyseerrSortOrder(value[0])}
- values={[jellyseerrSortOrder]}
+ set={(value) => setSeerrSortOrder(value[0])}
+ values={[seerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}
diff --git a/components/jellyseerr/Cast.tsx b/components/seerr/Cast.tsx
similarity index 88%
rename from components/jellyseerr/Cast.tsx
rename to components/seerr/Cast.tsx
index 99440bd8..9063ac65 100644
--- a/components/jellyseerr/Cast.tsx
+++ b/components/seerr/Cast.tsx
@@ -3,7 +3,7 @@ import type React from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
-import PersonPoster from "@/components/jellyseerr/PersonPoster";
+import PersonPoster from "@/components/seerr/PersonPoster";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
@@ -15,9 +15,7 @@ const CastSlide: React.FC<
details?.credits?.cast &&
details?.credits?.cast?.length > 0 && (
-
- {t("jellyseerr.cast")}
-
+ {t("seerr.cast")}
= ({
const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => {
- const { jellyseerrRegion: region, jellyseerrLocale: locale } =
- useJellyseerr();
+ const { seerrRegion: region, seerrLocale: locale } = useSeerr();
const { t } = useTranslation();
const releases = useMemo(
@@ -132,21 +131,21 @@ const DetailFacts: React.FC<
return (
details && (
- {t("jellyseerr.details")}
+ {t("seerr.details")}
-
+
{details.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID,
- ) && }
+ ) && }
(
{r.type === 3 ? (
@@ -172,16 +171,13 @@ const DetailFacts: React.FC<
))}
/>
-
-
-
-
-
+
+
+
+
+
(
@@ -190,17 +186,17 @@ const DetailFacts: React.FC<
))}
/>
n.name,
)}
/>
n.name)}
/>
s.name)}
/>
diff --git a/components/seerr/GridSkeleton.tsx b/components/seerr/GridSkeleton.tsx
new file mode 100644
index 00000000..75431cd7
--- /dev/null
+++ b/components/seerr/GridSkeleton.tsx
@@ -0,0 +1,21 @@
+import { View } from "react-native";
+
+interface Props {
+ index: number;
+}
+// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
+export const GridSkeleton: React.FC = ({ index }) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/jellyseerr/ParallaxSlideShow.tsx b/components/seerr/ParallaxSlideShow.tsx
similarity index 71%
rename from components/jellyseerr/ParallaxSlideShow.tsx
rename to components/seerr/ParallaxSlideShow.tsx
index b4b75592..261da73d 100644
--- a/components/jellyseerr/ParallaxSlideShow.tsx
+++ b/components/seerr/ParallaxSlideShow.tsx
@@ -11,6 +11,7 @@ import { Animated, View, type ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { GridSkeleton } from "./GridSkeleton";
const ANIMATION_ENTER = 250;
const ANIMATION_EXIT = 250;
@@ -28,6 +29,7 @@ interface Props {
renderItem: (item: T, index: number) => Render;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
+ isLoading?: boolean;
}
const ParallaxSlideShow = ({
@@ -40,6 +42,7 @@ const ParallaxSlideShow = ({
renderItem,
keyExtractor,
onEndReached,
+ isLoading = false,
}: PropsWithChildren & ViewProps>) => {
const insets = useSafeAreaInsets();
@@ -124,27 +127,40 @@ const ParallaxSlideShow = ({
{MainContent?.()}
-
-
- No results
-
-
- }
- contentInsetAdjustmentBehavior='automatic'
- ListHeaderComponent={
+ {isLoading ? (
+
{listHeader}
- }
- nestedScrollEnabled
- showsVerticalScrollIndicator={false}
- //@ts-expect-error
- renderItem={({ item, index }) => renderItem(item, index)}
- keyExtractor={keyExtractor}
- numColumns={3}
- ItemSeparatorComponent={() => }
- />
+
+
+ {Array.from({ length: 9 }, (_, i) => (
+
+ ))}
+
+
+
+ ) : (
+
+
+ No results
+
+
+ }
+ contentInsetAdjustmentBehavior='automatic'
+ ListHeaderComponent={
+ {listHeader}
+ }
+ nestedScrollEnabled
+ showsVerticalScrollIndicator={false}
+ //@ts-expect-error
+ renderItem={({ item, index }) => renderItem(item, index)}
+ keyExtractor={keyExtractor}
+ numColumns={3}
+ ItemSeparatorComponent={() => }
+ />
+ )}
diff --git a/components/jellyseerr/PersonPoster.tsx b/components/seerr/PersonPoster.tsx
similarity index 73%
rename from components/jellyseerr/PersonPoster.tsx
rename to components/seerr/PersonPoster.tsx
index 70e44d35..821cf52c 100644
--- a/components/jellyseerr/PersonPoster.tsx
+++ b/components/seerr/PersonPoster.tsx
@@ -1,9 +1,10 @@
-import { useRouter, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import type React from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
+import useRouter from "@/hooks/useAppRouter";
+import { useSeerr } from "@/hooks/useSeerr";
interface Props {
id: string;
@@ -19,7 +20,7 @@ const PersonPoster: React.FC = ({
subName,
...props
}) => {
- const { jellyseerrApi } = useJellyseerr();
+ const { seerrApi } = useSeerr();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -27,14 +28,12 @@ const PersonPoster: React.FC = ({
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
- router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)
- }
+ onPress={() => router.push(`/(auth)/(tabs)/${from}/seerr/person/${id}`)}
>
{name}
{subName && {subName}}
diff --git a/components/jellyseerr/RequestModal.tsx b/components/seerr/RequestModal.tsx
similarity index 80%
rename from components/jellyseerr/RequestModal.tsx
rename to components/seerr/RequestModal.tsx
index 5bc44177..53d23765 100644
--- a/components/jellyseerr/RequestModal.tsx
+++ b/components/seerr/RequestModal.tsx
@@ -12,7 +12,7 @@ import { View, type ViewProps } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useSeerr } from "@/hooks/useSeerr";
import type {
QualityProfile,
RootFolder,
@@ -41,11 +41,11 @@ const RequestModal = forwardRef<
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
ref,
) => {
- const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
+ const { seerrApi, seerrUser, requestMedia } = useSeerr();
const [requestOverrides, setRequestOverrides] = useState({
mediaId: Number(id),
mediaType: type,
- userId: jellyseerrUser?.id,
+ userId: seerrUser?.id,
});
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
@@ -65,18 +65,17 @@ const RequestModal = forwardRef<
}, [onDismiss]);
const { data: serviceSettings } = useQuery({
- queryKey: ["jellyseerr", "request", type, "service"],
+ queryKey: ["seerr", "request", type, "service"],
queryFn: async () =>
- jellyseerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
- enabled: !!jellyseerrApi && !!jellyseerrUser,
+ seerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
+ enabled: !!seerrApi && !!seerrUser,
refetchOnMount: "always",
});
const { data: users } = useQuery({
- queryKey: ["jellyseerr", "users"],
- queryFn: async () =>
- jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
- enabled: !!jellyseerrApi && !!jellyseerrUser,
+ queryKey: ["seerr", "users"],
+ queryFn: async () => seerrApi?.user({ take: 1000, sort: "displayname" }),
+ enabled: !!seerrApi && !!seerrUser,
refetchOnMount: "always",
});
@@ -87,7 +86,7 @@ const RequestModal = forwardRef<
const { data: defaultServiceDetails } = useQuery({
queryKey: [
- "jellyseerr",
+ "seerr",
"request",
type,
"service",
@@ -99,12 +98,12 @@ const RequestModal = forwardRef<
...prev,
serverId: defaultService?.id,
}));
- return jellyseerrApi?.serviceDetails(
+ return seerrApi?.serviceDetails(
type === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
- enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
+ enabled: !!seerrApi && !!seerrUser && !!defaultService,
refetchOnMount: "always",
});
@@ -148,9 +147,9 @@ const RequestModal = forwardRef<
return undefined;
}
if (requestBody.seasons.length > 1) {
- return t("jellyseerr.season_all");
+ return t("seerr.season_all");
}
- return t("jellyseerr.season_number", {
+ return t("seerr.season_number", {
season_number: requestBody.seasons[0],
});
}, [requestBody?.seasons]);
@@ -245,8 +244,7 @@ const RequestModal = forwardRef<
type: "radio" as const,
label: user.displayName,
value: user.id.toString(),
- selected:
- (requestOverrides.userId || jellyseerrUser?.id) === user.id,
+ selected: (requestOverrides.userId || seerrUser?.id) === user.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
@@ -255,7 +253,7 @@ const RequestModal = forwardRef<
})) || [],
},
],
- [users, jellyseerrUser, requestOverrides.userId],
+ [users, seerrUser, requestOverrides.userId],
);
const request = useCallback(() => {
@@ -268,7 +266,7 @@ const RequestModal = forwardRef<
...requestOverrides,
};
- writeDebugLog("Sending Jellyseerr advanced request", body);
+ writeDebugLog("Sending Seerr advanced request", body);
requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
@@ -308,7 +306,7 @@ const RequestModal = forwardRef<
- {t("jellyseerr.advanced")}
+ {t("seerr.advanced")}
{seasonTitle && (
{seasonTitle}
@@ -319,7 +317,7 @@ const RequestModal = forwardRef<
<>
- {t("jellyseerr.quality_profile")}
+ {t("seerr.quality_profile")}
}
- title={t("jellyseerr.quality_profile")}
+ title={t("seerr.quality_profile")}
open={qualityProfileOpen}
onOpenChange={setQualityProfileOpen}
/>
@@ -343,7 +341,7 @@ const RequestModal = forwardRef<
- {t("jellyseerr.root_folder")}
+ {t("seerr.root_folder")}
}
- title={t("jellyseerr.root_folder")}
+ title={t("seerr.root_folder")}
open={rootFolderOpen}
onOpenChange={setRootFolderOpen}
/>
-
-
- {t("jellyseerr.tags")}
-
-
-
- {requestOverrides.tags
- ? defaultServiceDetails.tags
- .filter((t) =>
- requestOverrides.tags!.includes(t.id),
- )
- .map((t) => t.label)
- .join(", ") ||
- defaultTags.map((t) => t.label).join(", ")
- : defaultTags.map((t) => t.label).join(", ")}
-
-
- }
- title={t("jellyseerr.tags")}
- open={tagsOpen}
- onOpenChange={setTagsOpen}
- />
-
+ {defaultServiceDetails?.tags &&
+ defaultServiceDetails.tags.length > 0 && (
+
+
+ {t("seerr.tags")}
+
+
+
+ {requestOverrides.tags
+ ? defaultServiceDetails.tags
+ .filter((t) =>
+ requestOverrides.tags!.includes(t.id),
+ )
+ .map((t) => t.label)
+ .join(", ") ||
+ defaultTags.map((t) => t.label).join(", ")
+ : defaultTags.map((t) => t.label).join(", ")}
+
+
+ }
+ title={t("seerr.tags")}
+ open={tagsOpen}
+ onOpenChange={setTagsOpen}
+ />
+
+ )}
- {t("jellyseerr.request_as")}
+ {t("seerr.request_as")}
u.id ===
- (requestOverrides.userId || jellyseerrUser?.id),
- )?.displayName || jellyseerrUser!.displayName}
+ (requestOverrides.userId || seerrUser?.id),
+ )?.displayName || seerrUser!.displayName}
}
- title={t("jellyseerr.request_as")}
+ title={t("seerr.request_as")}
open={usersOpen}
onOpenChange={setUsersOpen}
/>
@@ -427,7 +428,7 @@ const RequestModal = forwardRef<
)}
diff --git a/components/jellyseerr/JellyseerrIndexPage.tsx b/components/seerr/SeerrIndexPage.tsx
similarity index 65%
rename from components/jellyseerr/JellyseerrIndexPage.tsx
rename to components/seerr/SeerrIndexPage.tsx
index 42fd223b..6ef54fa6 100644
--- a/components/jellyseerr/JellyseerrIndexPage.tsx
+++ b/components/seerr/SeerrIndexPage.tsx
@@ -8,8 +8,8 @@ import {
useSharedValue,
withTiming,
} from "react-native-reanimated";
-import Discover from "@/components/jellyseerr/discover/Discover";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
+import Discover from "@/components/seerr/discover/Discover";
+import { useSeerr } from "@/hooks/useSeerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
@@ -18,57 +18,57 @@ import type {
} from "@/utils/jellyseerr/server/models/Search";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
import { Text } from "../common/Text";
-import JellyseerrPoster from "../posters/JellyseerrPoster";
+import SeerrPoster from "../posters/SeerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster";
interface Props extends ViewProps {
searchQuery: string;
- sortType?: JellyseerrSearchSort;
+ sortType?: SeerrSearchSort;
order?: "asc" | "desc";
}
-export enum JellyseerrSearchSort {
+export enum SeerrSearchSort {
DEFAULT = 0,
VOTE_COUNT_AND_AVERAGE = 1,
POPULARITY = 2,
}
-export const JellyserrIndexPage: React.FC = ({
+export const SeerrIndexPage: React.FC = ({
searchQuery,
sortType,
order,
}) => {
- const { jellyseerrApi } = useJellyseerr();
+ const { seerrApi } = useSeerr();
const opacity = useSharedValue(1);
const { t } = useTranslation();
const {
- data: jellyseerrDiscoverSettings,
+ data: seerrDiscoverSettings,
isFetching: f1,
isLoading: l1,
} = useReactNavigationQuery({
- queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
- queryFn: async () => jellyseerrApi?.discoverSettings(),
- enabled: !!jellyseerrApi && searchQuery.length === 0,
+ queryKey: ["search", "seerr", "discoverSettings", searchQuery],
+ queryFn: async () => seerrApi?.discoverSettings(),
+ enabled: !!seerrApi && searchQuery.length === 0,
});
const {
- data: jellyseerrResults,
+ data: seerrResults,
isFetching: f2,
isLoading: l2,
} = useReactNavigationQuery({
- queryKey: ["search", "jellyseerr", "results", searchQuery],
+ queryKey: ["search", "seerr", "results", searchQuery],
queryFn: async () => {
const params = {
query: new URLSearchParams(searchQuery || "").toString(),
};
return await Promise.all([
- jellyseerrApi?.search({ ...params, page: 1 }),
- jellyseerrApi?.search({ ...params, page: 2 }),
- jellyseerrApi?.search({ ...params, page: 3 }),
- jellyseerrApi?.search({ ...params, page: 4 }),
+ seerrApi?.search({ ...params, page: 1 }),
+ seerrApi?.search({ ...params, page: 2 }),
+ seerrApi?.search({ ...params, page: 3 }),
+ seerrApi?.search({ ...params, page: 4 }),
]).then((all) =>
uniqBy(
all.flatMap((v) => v?.results || []),
@@ -76,7 +76,7 @@ export const JellyserrIndexPage: React.FC = ({
),
);
},
- enabled: !!jellyseerrApi && searchQuery.length > 0,
+ enabled: !!seerrApi && searchQuery.length > 0,
});
useAnimatedReaction(
@@ -92,20 +92,20 @@ export const JellyserrIndexPage: React.FC = ({
const sortingType = useMemo(() => {
if (!sortType) return;
- switch (Number(JellyseerrSearchSort[sortType])) {
- case JellyseerrSearchSort.VOTE_COUNT_AND_AVERAGE:
+ switch (Number(SeerrSearchSort[sortType])) {
+ case SeerrSearchSort.VOTE_COUNT_AND_AVERAGE:
return ["voteCount", "voteAverage"];
- case JellyseerrSearchSort.POPULARITY:
+ case SeerrSearchSort.POPULARITY:
return ["voteCount", "popularity"];
default:
return undefined;
}
}, [sortType, order]);
- const jellyseerrMovieResults = useMemo(
+ const seerrMovieResults = useMemo(
() =>
orderBy(
- jellyseerrResults?.filter(
+ seerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE,
) as MovieResult[],
sortingType || [
@@ -113,41 +113,37 @@ export const JellyserrIndexPage: React.FC = ({
],
order || "desc",
),
- [jellyseerrResults, sortingType, order],
+ [seerrResults, sortingType, order],
);
- const jellyseerrTvResults = useMemo(
+ const seerrTvResults = useMemo(
() =>
orderBy(
- jellyseerrResults?.filter(
- (r) => r.mediaType === MediaType.TV,
- ) as TvResult[],
+ seerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
sortingType || [
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
],
order || "desc",
),
- [jellyseerrResults, sortingType, order],
+ [seerrResults, sortingType, order],
);
- const jellyseerrPersonResults = useMemo(
+ const seerrPersonResults = useMemo(
() =>
orderBy(
- jellyseerrResults?.filter(
- (r) => r.mediaType === "person",
- ) as PersonResult[],
+ seerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
sortingType || [
(p) => p.name.toLowerCase() === searchQuery.toLowerCase(),
],
order || "desc",
),
- [jellyseerrResults, sortingType, order],
+ [seerrResults, sortingType, order],
);
if (!searchQuery.length)
return (
-
+
);
@@ -155,9 +151,9 @@ export const JellyserrIndexPage: React.FC = ({
- {!jellyseerrMovieResults?.length &&
- !jellyseerrTvResults?.length &&
- !jellyseerrPersonResults?.length &&
+ {!seerrMovieResults?.length &&
+ !seerrTvResults?.length &&
+ !seerrPersonResults?.length &&
!f1 &&
!f2 &&
!l1 &&
@@ -175,21 +171,21 @@ export const JellyserrIndexPage: React.FC = ({
(
-
+
)}
/>
(
-
+
)}
/>
(
= ({ mediaType, className, ...props }) => {
+const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
+ mediaType,
+ className,
+ ...props
+}) => {
const style = useMemo(
() =>
mediaType === MediaType.MOVIE
@@ -29,4 +31,4 @@ const JellyseerrMediaIcon: React.FC<
);
};
-export default JellyseerrMediaIcon;
+export default SeerrMediaIcon;
diff --git a/components/jellyseerr/JellyseerrStatusIcon.tsx b/components/seerr/SeerrStatusIcon.tsx
similarity index 96%
rename from components/jellyseerr/JellyseerrStatusIcon.tsx
rename to components/seerr/SeerrStatusIcon.tsx
index e9b37af5..414fea62 100644
--- a/components/jellyseerr/JellyseerrStatusIcon.tsx
+++ b/components/seerr/SeerrStatusIcon.tsx
@@ -9,7 +9,7 @@ interface Props {
onPress?: () => void;
}
-const JellyseerrStatusIcon: React.FC = ({
+const SeerrStatusIcon: React.FC = ({
mediaStatus,
showRequestIcon,
onPress,
@@ -74,4 +74,4 @@ const JellyseerrStatusIcon: React.FC = ({
);
};
-export default JellyseerrStatusIcon;
+export default SeerrStatusIcon;
diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/seerr/discover/CompanySlide.tsx
similarity index 70%
rename from components/jellyseerr/discover/CompanySlide.tsx
rename to components/seerr/discover/CompanySlide.tsx
index 9643f48e..58e614fa 100644
--- a/components/jellyseerr/discover/CompanySlide.tsx
+++ b/components/seerr/discover/CompanySlide.tsx
@@ -1,10 +1,11 @@
-import { router, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
-import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
-import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
+import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
+import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
+import useRouter from "@/hooks/useAppRouter";
+import { useSeerr } from "@/hooks/useSeerr";
import {
COMPANY_LOGO_IMAGE_FILTER,
type Network,
@@ -15,13 +16,14 @@ const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps
> = ({ slide, data, ...props }) => {
const segments = useSegments();
- const { jellyseerrApi } = useJellyseerr();
+ const { seerrApi } = useSeerr();
+ const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
({ id, image, name }: Network | Studio) =>
router.push({
- pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
+ pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
params: { id, image, name, type: slide.type },
}),
[slide],
@@ -38,10 +40,7 @@ const CompanySlide: React.FC<
)}
diff --git a/components/jellyseerr/discover/Discover.tsx b/components/seerr/discover/Discover.tsx
similarity index 87%
rename from components/jellyseerr/discover/Discover.tsx
rename to components/seerr/discover/Discover.tsx
index 67e5bf3c..7a926eec 100644
--- a/components/jellyseerr/discover/Discover.tsx
+++ b/components/seerr/discover/Discover.tsx
@@ -2,10 +2,10 @@ import { sortBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
-import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
-import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
-import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
-import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
+import CompanySlide from "@/components/seerr/discover/CompanySlide";
+import GenreSlide from "@/components/seerr/discover/GenreSlide";
+import MovieTvSlide from "@/components/seerr/discover/MovieTvSlide";
+import RecentRequestsSlide from "@/components/seerr/discover/RecentRequestsSlide";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
diff --git a/components/jellyseerr/discover/GenericSlideCard.tsx b/components/seerr/discover/GenericSlideCard.tsx
similarity index 100%
rename from components/jellyseerr/discover/GenericSlideCard.tsx
rename to components/seerr/discover/GenericSlideCard.tsx
diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/seerr/discover/GenreSlide.tsx
similarity index 74%
rename from components/jellyseerr/discover/GenreSlide.tsx
rename to components/seerr/discover/GenreSlide.tsx
index 8edaf4c3..e65534d0 100644
--- a/components/jellyseerr/discover/GenreSlide.tsx
+++ b/components/seerr/discover/GenreSlide.tsx
@@ -1,39 +1,41 @@
import { useQuery } from "@tanstack/react-query";
-import { router, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
-import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
-import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
-import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
+import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
+import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
+import useRouter from "@/hooks/useAppRouter";
+import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
const GenreSlide: React.FC = ({ slide, ...props }) => {
const segments = useSegments();
- const { jellyseerrApi } = useJellyseerr();
+ const { seerrApi } = useSeerr();
+ const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
(genre: GenreSliderItem) =>
router.push({
- pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
+ pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
params: { type: slide.type, name: genre.name },
}),
[slide],
);
const { data } = useQuery({
- queryKey: ["jellyseerr", "discover", slide.type, slide.id],
+ queryKey: ["seerr", "discover", slide.type, slide.id],
queryFn: async () => {
- return jellyseerrApi?.getGenreSliders(
+ return seerrApi?.getGenreSliders(
slide.type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.MOVIE
: Endpoints.TV,
);
},
- enabled: !!jellyseerrApi,
+ enabled: !!seerrApi,
});
return (
@@ -51,7 +53,7 @@ const GenreSlide: React.FC = ({ slide, ...props }) => {
title={item.name}
colors={["transparent", "transparent"]}
contentFit={"cover"}
- url={jellyseerrApi?.imageProxy(
+ url={seerrApi?.imageProxy(
item.backdrops?.[0],
`w780_filter(duotone,${
genreColorMap[item.id] ?? genreColorMap[0]
diff --git a/components/jellyseerr/discover/MovieTvSlide.tsx b/components/seerr/discover/MovieTvSlide.tsx
similarity index 76%
rename from components/jellyseerr/discover/MovieTvSlide.tsx
rename to components/seerr/discover/MovieTvSlide.tsx
index a82f48a3..6291bab1 100644
--- a/components/jellyseerr/discover/MovieTvSlide.tsx
+++ b/components/seerr/discover/MovieTvSlide.tsx
@@ -3,23 +3,19 @@ import { uniqBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import type { ViewProps } from "react-native";
-import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
-import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
-import {
- type DiscoverEndpoint,
- Endpoints,
- useJellyseerr,
-} from "@/hooks/useJellyseerr";
+import SeerrPoster from "@/components/posters/SeerrPoster";
+import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
+import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
const MovieTvSlide: React.FC = ({
slide,
...props
}) => {
- const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
+ const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
- queryKey: ["jellyseerr", "discover", slide.id],
+ queryKey: ["seerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined;
let params: any = {
@@ -50,13 +46,13 @@ const MovieTvSlide: React.FC = ({
break;
}
- return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
+ return endpoint ? seerrApi?.discover(endpoint, params) : null;
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
- enabled: !!jellyseerrApi,
+ enabled: !!seerrApi,
staleTime: 0,
});
@@ -65,9 +61,7 @@ const MovieTvSlide: React.FC = ({
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
- .flatMap((p) =>
- p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
- ),
+ .flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
"id",
),
[data],
@@ -84,7 +78,7 @@ const MovieTvSlide: React.FC = ({
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
- renderItem={(item) => }
+ renderItem={(item) => }
/>
)
);
diff --git a/components/jellyseerr/discover/RecentRequestsSlide.tsx b/components/seerr/discover/RecentRequestsSlide.tsx
similarity index 70%
rename from components/jellyseerr/discover/RecentRequestsSlide.tsx
rename to components/seerr/discover/RecentRequestsSlide.tsx
index 71b5fc21..12cdef40 100644
--- a/components/jellyseerr/discover/RecentRequestsSlide.tsx
+++ b/components/seerr/discover/RecentRequestsSlide.tsx
@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import type React from "react";
import type { ViewProps } from "react-native";
-import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
-import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
+import SeerrPoster from "@/components/posters/SeerrPoster";
+import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
+import { useSeerr } from "@/hooks/useSeerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
@@ -16,36 +16,36 @@ type ExtendedMediaRequest = NonFunctionProperties & {
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request,
}) => {
- const { jellyseerrApi } = useJellyseerr();
+ const { seerrApi } = useSeerr();
const { data: details } = useQuery({
queryKey: [
- "jellyseerr",
+ "seerr",
"detail",
request.media.mediaType,
request.media.tmdbId,
],
queryFn: async () => {
return request.media.mediaType === MediaType.MOVIE
- ? jellyseerrApi?.movieDetails(request.media.tmdbId)
- : jellyseerrApi?.tvDetails(request.media.tmdbId);
+ ? seerrApi?.movieDetails(request.media.tmdbId)
+ : seerrApi?.tvDetails(request.media.tmdbId);
},
- enabled: !!jellyseerrApi,
+ enabled: !!seerrApi,
refetchOnMount: true,
staleTime: 0,
});
const { data: refreshedRequest } = useQuery({
- queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
- queryFn: async () => jellyseerrApi?.getRequest(request.id),
- enabled: !!jellyseerrApi,
+ queryKey: ["seerr", "requests", request.media.mediaType, request.id],
+ queryFn: async () => seerrApi?.getRequest(request.id),
+ enabled: !!seerrApi,
refetchOnMount: true,
refetchInterval: 5000,
staleTime: 0,
});
return (
- = ({
slide,
...props
}) => {
- const { jellyseerrApi } = useJellyseerr();
+ const { seerrApi } = useSeerr();
const { data: requests } = useQuery({
- queryKey: ["jellyseerr", "recent_requests"],
- queryFn: async () => jellyseerrApi?.requests(),
- enabled: !!jellyseerrApi,
+ queryKey: ["seerr", "recent_requests"],
+ queryFn: async () => seerrApi?.requests(),
+ enabled: !!seerrApi,
refetchOnMount: true,
staleTime: 0,
});
diff --git a/components/jellyseerr/discover/Slide.tsx b/components/seerr/discover/Slide.tsx
similarity index 100%
rename from components/jellyseerr/discover/Slide.tsx
rename to components/seerr/discover/Slide.tsx
diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx
index 037206e9..359539dc 100644
--- a/components/series/CastAndCrew.tsx
+++ b/components/series/CastAndCrew.tsx
@@ -2,12 +2,17 @@ import type {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
-import { router, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
+
+// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
+const POSTER_CAROUSEL_HEIGHT = 220;
+
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -23,6 +28,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments();
const { t } = useTranslation();
+ const router = useRouter();
const from = (segments as string[])[2];
const destinctPeople = useMemo(() => {
@@ -50,7 +56,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
i.Id?.toString() || ""}
- height={247}
+ height={POSTER_CAROUSEL_HEIGHT}
data={destinctPeople}
renderItem={(i) => (
= ({ item, loading, ...props }) => {
className='flex flex-col w-28'
>
- {i.Name}
- {i.Role}
+
+ {i.Name}
+
+
+ {i.Role}
+
)}
/>
diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx
index c3d0e0d9..3f9a5074 100644
--- a/components/series/CurrentSeries.tsx
+++ b/components/series/CurrentSeries.tsx
@@ -1,9 +1,13 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { router } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
+
+// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
+const POSTER_CAROUSEL_HEIGHT = 220;
+
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -17,6 +21,7 @@ interface Props extends ViewProps {
export const CurrentSeries: React.FC = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
+ const router = useRouter();
return (
@@ -25,7 +30,7 @@ export const CurrentSeries: React.FC = ({ item, ...props }) => {
(
= ({ item, ...props }) => {
id={item?.Id}
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
/>
- {item?.SeriesName}
+ {item?.SeriesName}
)}
/>
diff --git a/components/series/EpisodeTitleHeader.tsx b/components/series/EpisodeTitleHeader.tsx
index e9f2b1aa..fe473a92 100644
--- a/components/series/EpisodeTitleHeader.tsx
+++ b/components/series/EpisodeTitleHeader.tsx
@@ -1,7 +1,7 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useRouter } from "expo-router";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
interface Props extends ViewProps {
item: BaseItemDto;
diff --git a/components/series/NextItemButton.tsx b/components/series/NextItemButton.tsx
index a2aae63f..5d6497c3 100644
--- a/components/series/NextItemButton.tsx
+++ b/components/series/NextItemButton.tsx
@@ -2,9 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
-import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
+import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button";
diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx
index 5f6b64cd..b59b74a3 100644
--- a/components/series/SeasonDropdown.tsx
+++ b/components/series/SeasonDropdown.tsx
@@ -54,29 +54,28 @@ export const SeasonDropdown: React.FC = ({
[state, item, keys],
);
+ // Always use IndexNumber for Season objects (not keys.index which is for the item)
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
- Number(a[keys.index]) - Number(b[keys.index]);
+ Number(a.IndexNumber) - Number(b.IndexNumber);
const optionGroups = useMemo(
() => [
{
options:
seasons?.sort(sortByIndex).map((season: any) => {
- const title =
- season[keys.title] ||
- season.Name ||
- `Season ${season.IndexNumber}`;
+ const title = season.Name || `Season ${season.IndexNumber}`;
return {
type: "radio" as const,
label: title,
value: season.Id || season.IndexNumber,
- selected: Number(season[keys.index]) === Number(seasonIndex),
+ // Compare season's IndexNumber with the selected seasonIndex
+ selected: Number(season.IndexNumber) === Number(seasonIndex),
onPress: () => onSelect(season),
};
}) || [],
},
],
- [seasons, keys, seasonIndex, onSelect],
+ [seasons, seasonIndex, onSelect],
);
useEffect(() => {
diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx
index add0e83e..bb87ef12 100644
--- a/components/series/SeasonEpisodesCarousel.tsx
+++ b/components/series/SeasonEpisodesCarousel.tsx
@@ -1,12 +1,14 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
-import { router } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewStyle } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import { getDownloadedEpisodesBySeasonId } from "@/utils/downloads/offline-series";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -17,7 +19,6 @@ import { ItemCardText } from "../ItemCardText";
interface Props {
item?: BaseItemDto | null;
loading?: boolean;
- isOffline?: boolean;
style?: ViewStyle;
containerStyle?: ViewStyle;
}
@@ -25,17 +26,14 @@ interface Props {
export const SeasonEpisodesCarousel: React.FC = ({
item,
loading,
- isOffline,
style,
containerStyle,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const isOffline = useOfflineMode();
+ const router = useRouter();
const { getDownloadedItems } = useDownload();
- const downloadedFiles = useMemo(
- () => getDownloadedItems(),
- [getDownloadedItems],
- );
const scrollRef = useRef(null);
@@ -51,11 +49,7 @@ export const SeasonEpisodesCarousel: React.FC = ({
queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => {
if (isOffline) {
- return downloadedFiles
- ?.filter(
- (f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
- )
- .map((f) => f.item);
+ return getDownloadedEpisodesBySeasonId(getDownloadedItems(), seasonId!);
}
if (!api || !user?.Id || !item?.SeriesId) return [];
const response = await getTvShowsApi(api).getEpisodes({
@@ -73,7 +67,7 @@ export const SeasonEpisodesCarousel: React.FC = ({
});
return response.data.Items as BaseItemDto[];
},
- enabled: !!api && !!user?.Id && !!seasonId,
+ enabled: !!seasonId && (isOffline || (!!api && !!user?.Id)),
});
useEffect(() => {
diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx
index f97f6c0e..01a81545 100644
--- a/components/series/SeasonPicker.tsx
+++ b/components/series/SeasonPicker.tsx
@@ -10,7 +10,13 @@ import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
+import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import {
+ buildOfflineSeasons,
+ getDownloadedEpisodesForSeason,
+} from "@/utils/downloads/offline-series";
import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { Text } from "../common/Text";
@@ -31,6 +37,8 @@ export const SeasonPicker: React.FC = ({ item }) => {
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const { t } = useTranslation();
+ const isOffline = useOfflineMode();
+ const { getDownloadedItems, downloadedItems } = useDownload();
const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""],
@@ -38,8 +46,12 @@ export const SeasonPicker: React.FC = ({ item }) => {
);
const { data: seasons } = useQuery({
- queryKey: ["seasons", item.Id],
+ queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
queryFn: async () => {
+ if (isOffline) {
+ return buildOfflineSeasons(getDownloadedItems(), item.Id!);
+ }
+
if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Seasons`,
@@ -58,8 +70,8 @@ export const SeasonPicker: React.FC = ({ item }) => {
return response.data.Items;
},
- staleTime: 60,
- enabled: !!api && !!user?.Id && !!item.Id,
+ staleTime: isOffline ? Infinity : 60,
+ enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
});
const selectedSeasonId: string | null = useMemo(() => {
@@ -73,9 +85,33 @@ export const SeasonPicker: React.FC = ({ item }) => {
return season.Id!;
}, [seasons, seasonIndex]);
+ // For offline mode, we use season index number instead of ID
+ const selectedSeasonNumber = useMemo(() => {
+ if (!isOffline) return null;
+ const season = seasons?.find(
+ (s: BaseItemDto) =>
+ s.IndexNumber === seasonIndex || s.Name === seasonIndex,
+ );
+ return season?.IndexNumber ?? null;
+ }, [isOffline, seasons, seasonIndex]);
+
const { data: episodes, isPending } = useQuery({
- queryKey: ["episodes", item.Id, selectedSeasonId],
+ queryKey: [
+ "episodes",
+ item.Id,
+ isOffline ? selectedSeasonNumber : selectedSeasonId,
+ isOffline,
+ downloadedItems.length,
+ ],
queryFn: async () => {
+ if (isOffline) {
+ return getDownloadedEpisodesForSeason(
+ getDownloadedItems(),
+ item.Id!,
+ selectedSeasonNumber!,
+ );
+ }
+
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
return [];
}
@@ -85,7 +121,6 @@ export const SeasonPicker: React.FC = ({ item }) => {
userId: user.Id,
seasonId: selectedSeasonId,
enableUserData: true,
- // Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
@@ -97,7 +132,10 @@ export const SeasonPicker: React.FC = ({ item }) => {
return res.data.Items;
},
- enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
+ staleTime: isOffline ? Infinity : 0,
+ enabled: isOffline
+ ? !!item.Id && selectedSeasonNumber !== null
+ : !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
// Used for height calculation
@@ -127,7 +165,7 @@ export const SeasonPicker: React.FC = ({ item }) => {
}));
}}
/>
- {episodes?.length ? (
+ {episodes?.length && !isOffline ? (
= ({ item }) => {
{runtimeTicksToSeconds(e.RunTimeTicks)}
-
-
-
+ {!isOffline && (
+
+
+
+ )}
= ({ details, seasonNumber }) => {
- const { jellyseerrApi } = useJellyseerr();
+ const { seerrApi } = useSeerr();
const { data: seasonWithEpisodes, isLoading } = useQuery({
- queryKey: ["jellyseerr", details.id, "season", seasonNumber],
- queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
+ queryKey: ["seerr", details.id, "season", seasonNumber],
+ queryFn: async () => seerrApi?.tvSeason(details.id, seasonNumber),
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
});
@@ -57,11 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
};
const RenderItem = ({ item }: any) => {
- const {
- jellyseerrApi,
- jellyseerrRegion: region,
- jellyseerrLocale: locale,
- } = useJellyseerr();
+ const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
@@ -83,7 +79,7 @@ const RenderItem = ({ item }: any) => {
key={item.id}
id={item.id}
source={{
- uri: jellyseerrApi?.imageProxy(item.stillPath),
+ uri: seerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit='cover'
@@ -131,7 +127,7 @@ const RenderItem = ({ item }: any) => {
);
};
-const JellyseerrSeasons: React.FC<{
+const SeerrSeasons: React.FC<{
isLoading: boolean;
details?: TvDetails;
hasAdvancedRequest?: boolean;
@@ -148,7 +144,7 @@ const JellyseerrSeasons: React.FC<{
hasAdvancedRequest,
onAdvancedRequest,
}) => {
- const { jellyseerrApi, requestMedia } = useJellyseerr();
+ const { seerrApi, requestMedia } = useSeerr();
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
{},
);
@@ -181,7 +177,7 @@ const JellyseerrSeasons: React.FC<{
);
const requestAll = useCallback(() => {
- if (details && jellyseerrApi) {
+ if (details && seerrApi) {
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
@@ -198,7 +194,7 @@ const JellyseerrSeasons: React.FC<{
requestMedia(details.name, body, refetch);
}
}, [
- jellyseerrApi,
+ seerrApi,
seasons,
details,
hasAdvancedRequest,
@@ -210,15 +206,15 @@ const JellyseerrSeasons: React.FC<{
const promptRequestAll = useCallback(
() =>
Alert.alert(
- t("jellyseerr.confirm"),
- t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"),
+ t("seerr.confirm"),
+ t("seerr.are_you_sure_you_want_to_request_all_seasons"),
[
{
- text: t("jellyseerr.cancel"),
+ text: t("seerr.cancel"),
style: "cancel",
},
{
- text: t("jellyseerr.yes"),
+ text: t("seerr.yes"),
onPress: requestAll,
},
],
@@ -301,10 +297,10 @@ const JellyseerrSeasons: React.FC<{
{
const canRequest = season.status === MediaStatus.UNKNOWN;
return (
-
requestSeason(canRequest, season.seasonNumber)
@@ -326,7 +322,7 @@ const JellyseerrSeasons: React.FC<{
{seasonStates?.[season.seasonNumber] && (
- {
}
/>
+
+
+ updateSettings({ mergeNextUpAndContinueWatching: value })
+ }
+ />
+
router.push("/settings/appearance/hide-libraries/page")
@@ -57,6 +67,16 @@ export const AppearanceSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
showArrow
/>
+
+
+ updateSettings({ hideRemoteSessionButton: value })
+ }
+ />
+
);
diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx
index ef7b42f3..93a267bd 100644
--- a/components/settings/AudioToggles.tsx
+++ b/components/settings/AudioToggles.tsx
@@ -3,7 +3,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
-import { useSettings } from "@/utils/atoms/settings";
+import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -54,6 +54,70 @@ export const AudioToggles: React.FC = ({ ...props }) => {
];
}, [cultures, settings?.defaultAudioLanguage, t, updateSettings]);
+ const audioTranscodeModeLabels: Record = {
+ [AudioTranscodeMode.Auto]: t("home.settings.audio.transcode_mode.auto"),
+ [AudioTranscodeMode.ForceStereo]: t(
+ "home.settings.audio.transcode_mode.stereo",
+ ),
+ [AudioTranscodeMode.Allow51]: t("home.settings.audio.transcode_mode.5_1"),
+ [AudioTranscodeMode.AllowAll]: t(
+ "home.settings.audio.transcode_mode.passthrough",
+ ),
+ };
+
+ const audioTranscodeModeOptions = useMemo(
+ () => [
+ {
+ options: [
+ {
+ type: "radio" as const,
+ label: t("home.settings.audio.transcode_mode.auto"),
+ value: AudioTranscodeMode.Auto,
+ selected:
+ settings?.audioTranscodeMode === AudioTranscodeMode.Auto ||
+ !settings?.audioTranscodeMode,
+ onPress: () =>
+ updateSettings({ audioTranscodeMode: AudioTranscodeMode.Auto }),
+ },
+ {
+ type: "radio" as const,
+ label: t("home.settings.audio.transcode_mode.stereo"),
+ value: AudioTranscodeMode.ForceStereo,
+ selected:
+ settings?.audioTranscodeMode === AudioTranscodeMode.ForceStereo,
+ onPress: () =>
+ updateSettings({
+ audioTranscodeMode: AudioTranscodeMode.ForceStereo,
+ }),
+ },
+ {
+ type: "radio" as const,
+ label: t("home.settings.audio.transcode_mode.5_1"),
+ value: AudioTranscodeMode.Allow51,
+ selected:
+ settings?.audioTranscodeMode === AudioTranscodeMode.Allow51,
+ onPress: () =>
+ updateSettings({
+ audioTranscodeMode: AudioTranscodeMode.Allow51,
+ }),
+ },
+ {
+ type: "radio" as const,
+ label: t("home.settings.audio.transcode_mode.passthrough"),
+ value: AudioTranscodeMode.AllowAll,
+ selected:
+ settings?.audioTranscodeMode === AudioTranscodeMode.AllowAll,
+ onPress: () =>
+ updateSettings({
+ audioTranscodeMode: AudioTranscodeMode.AllowAll,
+ }),
+ },
+ ],
+ },
+ ],
+ [settings?.audioTranscodeMode, t, updateSettings],
+ );
+
if (isTv) return null;
if (!settings) return null;
@@ -98,6 +162,31 @@ export const AudioToggles: React.FC = ({ ...props }) => {
title={t("home.settings.audio.language")}
/>
+
+
+
+ {
+ audioTranscodeModeLabels[
+ settings?.audioTranscodeMode || AudioTranscodeMode.Auto
+ ]
+ }
+
+
+
+ }
+ title={t("home.settings.audio.transcode_mode.title")}
+ />
+
);
diff --git a/components/settings/Dashboard.tsx b/components/settings/Dashboard.tsx
deleted file mode 100644
index 768c6e0b..00000000
--- a/components/settings/Dashboard.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useRouter } from "expo-router";
-import { useTranslation } from "react-i18next";
-import { View } from "react-native";
-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 (
-
-
- router.push("/settings/dashboard/sessions")}
- title={t("home.settings.dashboard.sessions_title")}
- showArrow
- />
-
-
- );
-};
diff --git a/components/settings/DisabledSetting.tsx b/components/settings/DisabledSetting.tsx
index 04e24f86..2775331b 100644
--- a/components/settings/DisabledSetting.tsx
+++ b/components/settings/DisabledSetting.tsx
@@ -11,12 +11,12 @@ const DisabledSetting: React.FC<
}}
>
+ {children}
{disabled && showText && (
-
- {text ?? "Currently disabled by admin."}
+
+ {text ?? "Disabled by admin"}
)}
- {children}
);
diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx
deleted file mode 100644
index 3a0017ac..00000000
--- a/components/settings/DownloadSettings.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function DownloadSettings() {
- return null;
-}
diff --git a/components/settings/DownloadSettings.tv.tsx b/components/settings/DownloadSettings.tv.tsx
deleted file mode 100644
index 3a0017ac..00000000
--- a/components/settings/DownloadSettings.tv.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function DownloadSettings() {
- return null;
-}
diff --git a/components/settings/GestureControls.tsx b/components/settings/GestureControls.tsx
index 55aadaa1..b9c39ef4 100644
--- a/components/settings/GestureControls.tsx
+++ b/components/settings/GestureControls.tsx
@@ -19,7 +19,9 @@ export const GestureControls: React.FC = ({ ...props }) => {
() =>
pluginSettings?.enableHorizontalSwipeSkip?.locked === true &&
pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true &&
- pluginSettings?.enableRightSideVolumeSwipe?.locked === true,
+ pluginSettings?.enableRightSideVolumeSwipe?.locked === true &&
+ pluginSettings?.hideVolumeSlider?.locked === true &&
+ pluginSettings?.hideBrightnessSlider?.locked === true,
[pluginSettings],
);
@@ -77,6 +79,38 @@ export const GestureControls: React.FC = ({ ...props }) => {
}
/>
+
+
+
+ updateSettings({ hideVolumeSlider })
+ }
+ />
+
+
+
+
+ updateSettings({ hideBrightnessSlider })
+ }
+ />
+
);
diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx
deleted file mode 100644
index 470d40a2..00000000
--- a/components/settings/Jellyseerr.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { useAtom } from "jotai";
-import { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { View } from "react-native";
-import { toast } from "sonner-native";
-import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
-import { userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { Button } from "../Button";
-import { Input } from "../common/Input";
-import { Text } from "../common/Text";
-import { ListGroup } from "../list/ListGroup";
-import { ListItem } from "../list/ListItem";
-
-export const JellyseerrSettings = () => {
- const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
- useJellyseerr();
-
- const { t } = useTranslation();
-
- const [user] = useAtom(userAtom);
- const { settings, updateSettings } = useSettings();
-
- const [jellyseerrPassword, setJellyseerrPassword] = useState<
- string | undefined
- >(undefined);
-
- const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
- string | undefined
- >(settings?.jellyseerrServerUrl || undefined);
-
- const loginToJellyseerrMutation = useMutation({
- mutationFn: async () => {
- if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
- throw new Error("Missing server url");
- if (!user?.Name)
- throw new Error("Missing required information for login");
- const jellyseerrTempApi = new JellyseerrApi(
- jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
- );
- const testResult = await jellyseerrTempApi.test();
- if (!testResult.isValid) throw new Error("Invalid server url");
- return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
- },
- onSuccess: (user) => {
- setJellyseerrUser(user);
- updateSettings({ jellyseerrServerUrl });
- },
- onError: () => {
- toast.error(t("jellyseerr.failed_to_login"));
- },
- onSettled: () => {
- setJellyseerrPassword(undefined);
- },
- });
-
- const clearData = () => {
- clearAllJellyseerData().finally(() => {
- setJellyseerrUser(undefined);
- setJellyseerrPassword(undefined);
- setjellyseerrServerUrl(undefined);
- });
- };
-
- return (
-
-
- {jellyseerrUser ? (
- <>
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : (
-
-
- {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
-
-
- {t("home.settings.plugins.jellyseerr.server_url")}
-
-
-
- {t("home.settings.plugins.jellyseerr.server_url_hint")}
-
-
-
-
-
- {t("home.settings.plugins.jellyseerr.password")}
-
-
-
-
-
- )}
-
-
- );
-};
diff --git a/components/settings/KefinTweaks.tsx b/components/settings/KefinTweaks.tsx
new file mode 100644
index 00000000..228b4692
--- /dev/null
+++ b/components/settings/KefinTweaks.tsx
@@ -0,0 +1,33 @@
+import { useTranslation } from "react-i18next";
+import { Switch, Text, View } from "react-native";
+import { useSettings } from "@/utils/atoms/settings";
+
+export const KefinTweaksSettings = () => {
+ const { settings, updateSettings } = useSettings();
+ const { t } = useTranslation();
+
+ const isEnabled = settings?.useKefinTweaks ?? false;
+
+ return (
+
+
+
+ {t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
+
+
+
+
+ {isEnabled ? t("Watchlist On") : t("Watchlist Off")}
+
+
+ updateSettings({ useKefinTweaks: value })}
+ trackColor={{ false: "#555", true: "purple" }}
+ thumbColor={isEnabled ? "#fff" : "#ccc"}
+ />
+
+
+
+ );
+};
diff --git a/components/settings/LibraryOptionsSheet.tsx b/components/settings/LibraryOptionsSheet.tsx
index c84989b5..e02e22fa 100644
--- a/components/settings/LibraryOptionsSheet.tsx
+++ b/components/settings/LibraryOptionsSheet.tsx
@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC = ({
/>
-
+
string;
+}
+
+function StatusDisplay({
+ currentSSID,
+ isUsingLocalUrl,
+ t,
+}: StatusDisplayProps): React.ReactElement {
+ const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
+ const urlType = isUsingLocalUrl
+ ? t("home.settings.network.local")
+ : t("home.settings.network.remote");
+ const urlTypeColor = isUsingLocalUrl ? "text-green-500" : "text-blue-500";
+
+ return (
+
+
+
+ {t("home.settings.network.current_wifi")}
+
+ {wifiStatus}
+
+
+
+ {t("home.settings.network.using_url")}
+
+ {urlType}
+
+
+ );
+}
+
+export function LocalNetworkSettings(): React.ReactElement | null {
+ const { t } = useTranslation();
+ const { permissionStatus, requestPermission } = useWifiSSID();
+ const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
+
+ const remoteUrl = storage.getString("serverUrl");
+ const [config, setConfig] = useState(DEFAULT_CONFIG);
+
+ useEffect(() => {
+ if (remoteUrl) {
+ const existingConfig = getServerLocalConfig(remoteUrl);
+ if (existingConfig) {
+ setConfig(existingConfig);
+ }
+ }
+ }, [remoteUrl]);
+
+ const saveConfig = useCallback(
+ (newConfig: LocalNetworkConfig) => {
+ if (!remoteUrl) return;
+ setConfig(newConfig);
+ updateServerLocalConfig(remoteUrl, newConfig);
+ // Trigger URL re-evaluation after config change
+ refreshUrlState();
+ },
+ [remoteUrl, refreshUrlState],
+ );
+
+ const handleToggleEnabled = useCallback(
+ async (enabled: boolean) => {
+ if (enabled && permissionStatus !== "granted") {
+ const granted = await requestPermission();
+ if (!granted) {
+ toast.error(t("home.settings.network.permission_denied"));
+ return;
+ }
+ }
+ saveConfig({ ...config, enabled });
+ },
+ [config, permissionStatus, requestPermission, saveConfig, t],
+ );
+
+ const handleLocalUrlChange = useCallback(
+ (localUrl: string) => {
+ saveConfig({ ...config, localUrl });
+ },
+ [config, saveConfig],
+ );
+
+ const handleAddCurrentNetwork = useCallback(() => {
+ if (!currentSSID) {
+ toast.error(t("home.settings.network.no_wifi_connected"));
+ return;
+ }
+ if (config.homeWifiSSIDs.includes(currentSSID)) {
+ toast.info(t("home.settings.network.network_already_added"));
+ return;
+ }
+ saveConfig({
+ ...config,
+ homeWifiSSIDs: [...config.homeWifiSSIDs, currentSSID],
+ });
+ toast.success(t("home.settings.network.network_added"));
+ }, [config, currentSSID, saveConfig, t]);
+
+ const handleRemoveNetwork = useCallback(
+ (ssidToRemove: string) => {
+ saveConfig({
+ ...config,
+ homeWifiSSIDs: config.homeWifiSSIDs.filter((s) => s !== ssidToRemove),
+ });
+ },
+ [config, saveConfig],
+ );
+
+ if (!remoteUrl) return null;
+
+ const addNetworkButtonText = currentSSID
+ ? t("home.settings.network.add_current_network", { ssid: currentSSID })
+ : t("home.settings.network.not_connected_to_wifi");
+
+ return (
+
+
+
+
+
+
+
+ {config.enabled && (
+
+
+ {t("home.settings.network.local_url_hint")}
+
+ }
+ >
+
+
+
+
+
+
+ {config.homeWifiSSIDs.map((wifiSSID) => (
+
+ handleRemoveNetwork(wifiSSID)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+
+ ))}
+ {config.homeWifiSSIDs.length === 0 && (
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+
+ {permissionStatus === "denied" && (
+
+
+ {t("home.settings.network.permission_denied_explanation")}
+
+
+ )}
+
+ );
+}
diff --git a/components/settings/MediaContext.tsx b/components/settings/MediaContext.tsx
index c6c7856e..67bcf8ea 100644
--- a/components/settings/MediaContext.tsx
+++ b/components/settings/MediaContext.tsx
@@ -4,9 +4,10 @@ import type {
UserDto,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { createContext, type ReactNode, useContext, useEffect } from "react";
+import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { apiAtom } from "@/providers/JellyfinProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings";
@@ -30,7 +31,7 @@ export const useMedia = () => {
export const MediaProvider = ({ children }: { children: ReactNode }) => {
const { settings, updateSettings } = useSettings();
const api = useAtomValue(apiAtom);
- const queryClient = useQueryClient();
+ const queryClient = useNetworkAwareQueryClient();
const updateSetingsWrapper = (update: Partial) => {
const updateUserConfiguration = async (
diff --git a/components/settings/MpvSubtitleSettings.tsx b/components/settings/MpvSubtitleSettings.tsx
new file mode 100644
index 00000000..889eaf76
--- /dev/null
+++ b/components/settings/MpvSubtitleSettings.tsx
@@ -0,0 +1,135 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, View, type ViewProps } from "react-native";
+import { Stepper } from "@/components/inputs/Stepper";
+import { Text } from "../common/Text";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
+import { PlatformDropdown } from "../PlatformDropdown";
+import { useMedia } from "./MediaContext";
+
+interface Props extends ViewProps {}
+
+type AlignX = "left" | "center" | "right";
+type AlignY = "top" | "center" | "bottom";
+
+export const MpvSubtitleSettings: React.FC = ({ ...props }) => {
+ const isTv = Platform.isTV;
+ const media = useMedia();
+ const { settings, updateSettings } = media;
+ const { t } = useTranslation();
+
+ const alignXOptions: AlignX[] = ["left", "center", "right"];
+ const alignYOptions: AlignY[] = ["top", "center", "bottom"];
+
+ const alignXLabels: Record = {
+ left: t("player.alignment_left"),
+ center: t("player.alignment_center"),
+ right: t("player.alignment_right"),
+ };
+
+ const alignYLabels: Record = {
+ top: t("player.alignment_top"),
+ center: t("player.alignment_center"),
+ bottom: t("player.alignment_bottom"),
+ };
+
+ const alignXOptionGroups = useMemo(() => {
+ const options = alignXOptions.map((align) => ({
+ type: "radio" as const,
+ label: alignXLabels[align],
+ value: align,
+ selected: align === (settings?.mpvSubtitleAlignX ?? "center"),
+ onPress: () => updateSettings({ mpvSubtitleAlignX: align }),
+ }));
+ return [{ options }];
+ }, [settings?.mpvSubtitleAlignX, updateSettings]);
+
+ const alignYOptionGroups = useMemo(() => {
+ const options = alignYOptions.map((align) => ({
+ type: "radio" as const,
+ label: alignYLabels[align],
+ value: align,
+ selected: align === (settings?.mpvSubtitleAlignY ?? "bottom"),
+ onPress: () => updateSettings({ mpvSubtitleAlignY: align }),
+ }));
+ return [{ options }];
+ }, [settings?.mpvSubtitleAlignY, updateSettings]);
+
+ if (isTv) return null;
+ if (!settings) return null;
+
+ return (
+
+
+ {t("player.mpv_subtitle_settings_description")}
+
+ }
+ >
+
+
+ updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 })
+ }
+ />
+
+
+
+ updateSettings({ mpvSubtitleMarginY: value })}
+ />
+
+
+
+
+
+ {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]}
+
+
+
+ }
+ title={t("player.horizontal_alignment")}
+ />
+
+
+
+
+
+ {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]}
+
+
+
+ }
+ title={t("player.vertical_alignment")}
+ />
+
+
+
+ );
+};
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index 452edab0..fcca2498 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -1,5 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
-import { useRouter } from "expo-router";
import { TFunction } from "i18next";
import type React from "react";
import { useMemo } from "react";
@@ -8,6 +7,7 @@ import { Linking, Switch, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting";
+import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
@@ -141,36 +141,6 @@ export const OtherSettings: React.FC = () => {
/>
- {/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
-
- t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
- title={
-
-
- {t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
-
-
-
- }
- label={t("home.settings.other.orientation")}
- onSelected={(defaultPlayer) =>
- updateSettings({ defaultPlayer })
- }
- />
-
- )} */}
-
{
const orientations = [
ScreenOrientation.OrientationLock.DEFAULT,
ScreenOrientation.OrientationLock.PORTRAIT_UP,
+ ScreenOrientation.OrientationLock.LANDSCAPE,
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
];
@@ -38,6 +40,8 @@ export const PlaybackControlsSettings: React.FC = () => {
"home.settings.other.orientations.DEFAULT",
[ScreenOrientation.OrientationLock.PORTRAIT_UP]:
"home.settings.other.orientations.PORTRAIT_UP",
+ [ScreenOrientation.OrientationLock.LANDSCAPE]:
+ "home.settings.other.orientations.LANDSCAPE",
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]:
"home.settings.other.orientations.LANDSCAPE_LEFT",
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]:
@@ -92,6 +96,21 @@ export const PlaybackControlsSettings: React.FC = () => {
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
);
+ const playbackSpeedOptions = useMemo(
+ () => [
+ {
+ options: PLAYBACK_SPEEDS.map((speed) => ({
+ type: "radio" as const,
+ label: speed.label,
+ value: speed.value,
+ selected: speed.value === settings?.defaultPlaybackSpeed,
+ onPress: () => updateSettings({ defaultPlaybackSpeed: speed.value }),
+ })),
+ },
+ ],
+ [settings?.defaultPlaybackSpeed, updateSettings],
+ );
+
if (!settings) return null;
return (
@@ -158,6 +177,30 @@ export const PlaybackControlsSettings: React.FC = () => {
/>
+
+
+
+ {PLAYBACK_SPEEDS.find(
+ (s) => s.value === settings.defaultPlaybackSpeed,
+ )?.label ?? "1x"}
+
+
+
+ }
+ title={t("home.settings.other.default_playback_speed")}
+ />
+
+
{
/>
-
+
+
+ updateSettings({ autoPlayNextEpisode })
+ }
+ />
+
+
+
{
className='mb-4'
>
router.push("/settings/plugins/jellyseerr/page")}
- title={"Jellyseerr"}
+ onPress={() => router.push("/settings/plugins/seerr/page")}
+ title={"Seerr"}
+ showArrow
+ />
+ router.push("/settings/plugins/streamystats/page")}
+ title={"Streamystats"}
showArrow
/>
router.push("/settings/plugins/marlin-search/page")}
- title='Marlin Search'
+ title={"Marlin Search"}
+ showArrow
+ />
+ router.push("/settings/plugins/kefinTweaks/page")}
+ title={"KefinTweaks"}
showArrow
/>
diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx
index d0cbf85e..725e7e6f 100644
--- a/components/settings/QuickConnect.tsx
+++ b/components/settings/QuickConnect.tsx
@@ -7,7 +7,7 @@ import {
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import type React from "react";
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
@@ -28,6 +28,11 @@ export const QuickConnect: React.FC = ({ ...props }) => {
const bottomSheetModalRef = useRef(null);
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
+ const snapPoints = useMemo(
+ () => (Platform.OS === "android" ? ["100%"] : ["40%"]),
+ [],
+ );
+ const isAndroid = Platform.OS === "android";
const { t } = useTranslation();
@@ -92,7 +97,7 @@ export const QuickConnect: React.FC = ({ ...props }) => {
= ({ ...props }) => {
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
- keyboardBehavior='interactive'
+ keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
+ topInset={isAndroid ? 0 : undefined}
>
diff --git a/components/settings/Seerr.tsx b/components/settings/Seerr.tsx
new file mode 100644
index 00000000..1ad87e7f
--- /dev/null
+++ b/components/settings/Seerr.tsx
@@ -0,0 +1,174 @@
+import { useMutation } from "@tanstack/react-query";
+import { useAtom } from "jotai";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { View } from "react-native";
+import { toast } from "sonner-native";
+import { SeerrApi, useSeerr } from "@/hooks/useSeerr";
+import { userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { Button } from "../Button";
+import { Input } from "../common/Input";
+import { Text } from "../common/Text";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
+
+export const SeerrSettings = () => {
+ const { seerrUser, setSeerrUser, clearAllSeerrData } = useSeerr();
+
+ const { t } = useTranslation();
+
+ const [user] = useAtom(userAtom);
+ const { settings, updateSettings } = useSettings();
+
+ const [seerrPassword, setSeerrPassword] = useState(
+ undefined,
+ );
+
+ const [seerrServerUrl, setSeerrServerUrl] = useState(
+ 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 (
+
+
+ {seerrUser ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
+ {t("home.settings.plugins.seerr.seerr_warning")}
+
+
+ {t("home.settings.plugins.seerr.server_url")}
+
+
+
+ {t("home.settings.plugins.seerr.server_url_hint")}
+
+
+
+
+
+ {t("home.settings.plugins.seerr.password")}
+
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx
index 5a6bfee3..526cb862 100644
--- a/components/settings/SubtitleToggles.tsx
+++ b/components/settings/SubtitleToggles.tsx
@@ -5,12 +5,6 @@ import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { Stepper } from "@/components/inputs/Stepper";
-import {
- OUTLINE_THICKNESS,
- type OutlineThickness,
- VLC_COLORS,
- type VLCColor,
-} from "@/constants/SubtitleConstants";
import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
@@ -92,84 +86,6 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
];
}, [settings?.subtitleMode, t, updateSettings]);
- const textColorOptionGroups = useMemo(() => {
- const colors = Object.keys(VLC_COLORS) as VLCColor[];
- const options = colors.map((color) => ({
- type: "radio" as const,
- label: t(`home.settings.subtitles.colors.${color}`),
- value: color,
- selected: (settings?.vlcTextColor || "White") === color,
- onPress: () => updateSettings({ vlcTextColor: color }),
- }));
-
- return [{ options }];
- }, [settings?.vlcTextColor, t, updateSettings]);
-
- const backgroundColorOptionGroups = useMemo(() => {
- const colors = Object.keys(VLC_COLORS) as VLCColor[];
- const options = colors.map((color) => ({
- type: "radio" as const,
- label: t(`home.settings.subtitles.colors.${color}`),
- value: color,
- selected: (settings?.vlcBackgroundColor || "Black") === color,
- onPress: () => updateSettings({ vlcBackgroundColor: color }),
- }));
-
- return [{ options }];
- }, [settings?.vlcBackgroundColor, t, updateSettings]);
-
- const outlineColorOptionGroups = useMemo(() => {
- const colors = Object.keys(VLC_COLORS) as VLCColor[];
- const options = colors.map((color) => ({
- type: "radio" as const,
- label: t(`home.settings.subtitles.colors.${color}`),
- value: color,
- selected: (settings?.vlcOutlineColor || "Black") === color,
- onPress: () => updateSettings({ vlcOutlineColor: color }),
- }));
-
- return [{ options }];
- }, [settings?.vlcOutlineColor, t, updateSettings]);
-
- const outlineThicknessOptionGroups = useMemo(() => {
- const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
- const options = thicknesses.map((thickness) => ({
- type: "radio" as const,
- label: t(`home.settings.subtitles.thickness.${thickness}`),
- value: thickness,
- selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
- onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
- }));
-
- return [{ options }];
- }, [settings?.vlcOutlineThickness, t, updateSettings]);
-
- const backgroundOpacityOptionGroups = useMemo(() => {
- const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
- const options = opacities.map((opacity) => ({
- type: "radio" as const,
- label: `${Math.round((opacity / 255) * 100)}%`,
- value: opacity,
- selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
- onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
- }));
-
- return [{ options }];
- }, [settings?.vlcBackgroundOpacity, updateSettings]);
-
- const outlineOpacityOptionGroups = useMemo(() => {
- const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
- const options = opacities.map((opacity) => ({
- type: "radio" as const,
- label: `${Math.round((opacity / 255) * 100)}%`,
- value: opacity,
- selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
- onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
- }));
-
- return [{ options }];
- }, [settings?.vlcOutlineOpacity, updateSettings]);
-
if (isTv) return null;
if (!settings) return null;
@@ -244,130 +160,14 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
disabled={pluginSettings?.subtitleSize?.locked}
>
updateSettings({ subtitleSize })}
- />
-
-
-
-
- {t(
- `home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
- )}
-
-
-
+ step={0.1}
+ min={0.3}
+ max={1.5}
+ onUpdate={(value) =>
+ updateSettings({ subtitleSize: Math.round(value * 100) })
}
- title={t("home.settings.subtitles.text_color")}
- />
-
-
-
-
- {t(
- `home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
- )}
-
-
-
- }
- title={t("home.settings.subtitles.background_color")}
- />
-
-
-
-
- {t(
- `home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
- )}
-
-
-
- }
- title={t("home.settings.subtitles.outline_color")}
- />
-
-
-
-
- {t(
- `home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
- )}
-
-
-
- }
- title={t("home.settings.subtitles.outline_thickness")}
- />
-
-
-
- {`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}
-
-
- }
- title={t("home.settings.subtitles.background_opacity")}
- />
-
-
-
- {`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}
-
-
- }
- title={t("home.settings.subtitles.outline_opacity")}
- />
-
-
- updateSettings({ vlcIsBold: value })}
/>
diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx
index 87f92db6..db0f9b96 100644
--- a/components/stacks/NestedTabPageStack.tsx
+++ b/components/stacks/NestedTabPageStack.tsx
@@ -19,7 +19,14 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => ,
};
-const routes = ["persons/[personId]", "items/page", "series/[id]"];
+const routes = [
+ "persons/[personId]",
+ "items/page",
+ "series/[id]",
+ "music/album/[albumId]",
+ "music/artist/[artistId]",
+ "music/playlist/[playlistId]",
+];
export const nestedTabPageScreenOptions: Record =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx
index a2652d70..51abf68c 100644
--- a/components/video-player/controls/BottomControls.tsx
+++ b/components/video-player/controls/BottomControls.tsx
@@ -18,9 +18,9 @@ interface BottomControlsProps {
showRemoteBubble: boolean;
currentTime: number;
remainingTime: number;
- isVlc: boolean;
showSkipButton: boolean;
showSkipCreditButton: boolean;
+ hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
nextItem?: BaseItemDto | null;
@@ -66,9 +66,9 @@ export const BottomControls: FC = ({
showRemoteBubble,
currentTime,
remainingTime,
- isVlc,
showSkipButton,
showSkipCreditButton,
+ hasContentAfterCredits,
skipIntro,
skipCredit,
nextItem,
@@ -96,11 +96,13 @@ export const BottomControls: FC = ({
style={[
{
position: "absolute",
- right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
- left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
- bottom: settings?.safeAreaInControlsEnabled
- ? Math.max(insets.bottom - 17, 0)
- : 0,
+ right:
+ (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
+ left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
+ bottom:
+ (settings?.safeAreaInControlsEnabled ?? true)
+ ? Math.max(insets.bottom - 17, 0)
+ : 0,
},
]}
className={"flex flex-col px-2"}
@@ -136,26 +138,32 @@ export const BottomControls: FC = ({
onPress={skipIntro}
buttonText='Skip Intro'
/>
+ {/* Smart Skip Credits behavior:
+ - Show "Skip Credits" if there's content after credits OR no next episode
+ - Show "Next Episode" if credits extend to video end AND next episode exists */}
- {(settings.maxAutoPlayEpisodeCount.value === -1 ||
- settings.autoPlayEpisodeCount <
- settings.maxAutoPlayEpisodeCount.value) && (
-
- )}
+ {settings.autoPlayNextEpisode !== false &&
+ (settings.maxAutoPlayEpisodeCount.value === -1 ||
+ settings.autoPlayEpisodeCount <
+ settings.maxAutoPlayEpisodeCount.value) && (
+
+ )}
= ({
diff --git a/components/video-player/controls/BrightnessSlider.tsx b/components/video-player/controls/BrightnessSlider.tsx
index db92bd10..eab17bca 100644
--- a/components/video-player/controls/BrightnessSlider.tsx
+++ b/components/video-player/controls/BrightnessSlider.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { useSharedValue } from "react-native-reanimated";
@@ -16,10 +16,19 @@ const BrightnessSlider = () => {
const max = useSharedValue(100);
const isUserInteracting = useRef(false);
const lastKnownBrightness = useRef(50);
+ const brightnessSupportedRef = useRef(true);
+ const [brightnessSupported, setBrightnessSupported] = useState(true);
// Update brightness from device
const updateBrightnessFromDevice = async () => {
- if (isTv || !Brightness || isUserInteracting.current) return;
+ // Check ref (not state) to avoid stale closure in setInterval
+ if (
+ isTv ||
+ !Brightness ||
+ isUserInteracting.current ||
+ !brightnessSupportedRef.current
+ )
+ return;
try {
const currentBrightness = await Brightness.getBrightnessAsync();
@@ -31,7 +40,10 @@ const BrightnessSlider = () => {
lastKnownBrightness.current = brightnessPercent;
}
} catch (error) {
- console.error("Error fetching brightness:", error);
+ console.warn("Brightness not supported on this device:", error);
+ // Update both ref (stops interval) and state (triggers re-render to hide)
+ brightnessSupportedRef.current = false;
+ setBrightnessSupported(false);
}
};
@@ -66,7 +78,7 @@ const BrightnessSlider = () => {
}, 100);
};
- if (isTv) return null;
+ if (isTv || !brightnessSupported) return null;
return (
diff --git a/components/video-player/controls/CenterControls.tsx b/components/video-player/controls/CenterControls.tsx
index 47659746..76ce4bed 100644
--- a/components/video-player/controls/CenterControls.tsx
+++ b/components/video-player/controls/CenterControls.tsx
@@ -38,8 +38,8 @@ export const CenterControls: FC = ({
style={{
position: "absolute",
top: "50%",
- left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
- right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
+ left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
+ right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
@@ -48,17 +48,19 @@ export const CenterControls: FC = ({
}}
pointerEvents={showControls ? "box-none" : "none"}
>
-
-
-
+ {!settings?.hideBrightnessSlider && (
+
+
+
+ )}
{!Platform.isTV && (
@@ -135,18 +137,20 @@ export const CenterControls: FC = ({
)}
-
-
-
+ {!settings?.hideVolumeSlider && (
+
+
+
+ )}
);
};
diff --git a/components/video-player/controls/ContinueWatchingOverlay.tsx b/components/video-player/controls/ContinueWatchingOverlay.tsx
index 4c353dee..26f82484 100644
--- a/components/video-player/controls/ContinueWatchingOverlay.tsx
+++ b/components/video-player/controls/ContinueWatchingOverlay.tsx
@@ -1,9 +1,9 @@
-import { useRouter } from "expo-router";
import { t } from "i18next";
import React from "react";
import { View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings";
export interface ContinueWatchingOverlayProps {
@@ -23,7 +23,7 @@ const ContinueWatchingOverlay: React.FC = ({
settings.maxAutoPlayEpisodeCount.value ? (
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index fb62fcef..1407c218 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -3,17 +3,9 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
-import { useLocalSearchParams, useRouter } from "expo-router";
-import {
- type Dispatch,
- type FC,
- type MutableRefObject,
- type SetStateAction,
- useCallback,
- useEffect,
- useState,
-} from "react";
-import { useWindowDimensions } from "react-native";
+import { useLocalSearchParams } from "expo-router";
+import { type FC, useCallback, useEffect, useState } from "react";
+import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, {
Easing,
type SharedValue,
@@ -23,20 +15,20 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
+import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
-import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import { DownloadedItem } from "@/providers/Downloads/types";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls";
import { CONTROLS_CONSTANTS } from "./constants";
-import { ControlProvider } from "./contexts/ControlContext";
import { EpisodeList } from "./EpisodeList";
import { GestureOverlay } from "./GestureOverlay";
import { HeaderControls } from "./HeaderControls";
@@ -44,44 +36,32 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime";
-import { type ScaleFactor } from "./ScaleFactorSelector";
import { useControlsTimeout } from "./useControlsTimeout";
-import { type AspectRatio } from "./VideoScalingModeSelector";
+import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
interface Props {
item: BaseItemDto;
- videoRef: MutableRefObject;
isPlaying: boolean;
isSeeking: SharedValue;
cacheProgress: SharedValue;
progress: SharedValue;
isBuffering: boolean;
showControls: boolean;
-
enableTrickplay?: boolean;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
- offline?: boolean;
- isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture?: () => Promise;
play: () => void;
pause: () => void;
- getAudioTracks?: (() => Promise) | (() => TrackInfo[]);
- getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]);
- setSubtitleURL?: (url: string, customName: string) => void;
- setSubtitleTrack?: (index: number) => void;
- setAudioTrack?: (index: number) => void;
- setVideoAspectRatio?: (aspectRatio: string | null) => Promise;
- setVideoScaleFactor?: (scaleFactor: number) => Promise;
- aspectRatio?: AspectRatio;
- scaleFactor?: ScaleFactor;
- setAspectRatio?: Dispatch>;
- setScaleFactor?: Dispatch>;
- isVlc?: boolean;
+ isZoomedToFill?: boolean;
+ onZoomToggle?: () => void;
api?: Api | null;
downloadedFiles?: DownloadedItem[];
+ // Playback speed props
+ playbackSpeed?: number;
+ setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
}
export const Controls: FC = ({
@@ -99,23 +79,14 @@ export const Controls: FC = ({
showControls,
setShowControls,
mediaSource,
- isVideoLoaded,
- getAudioTracks,
- getSubtitleTracks,
- setSubtitleURL,
- setSubtitleTrack,
- setAudioTrack,
- setVideoAspectRatio,
- setVideoScaleFactor,
- aspectRatio = "default",
- scaleFactor = 1.0,
- setAspectRatio,
- setScaleFactor,
- offline = false,
- isVlc = false,
+ isZoomedToFill = false,
+ onZoomToggle,
api = null,
downloadedFiles = undefined,
+ playbackSpeed = 1.0,
+ setPlaybackSpeed,
}) => {
+ const offline = useOfflineMode();
const { settings, updateSettings } = useSettings();
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
@@ -137,7 +108,9 @@ export const Controls: FC = ({
} = useTrickplay(item);
const min = useSharedValue(0);
- const max = useSharedValue(item.RunTimeTicks || 0);
+ // Regular value for use during render (avoids Reanimated warning)
+ const maxMs = ticksToMs(item.RunTimeTicks || 0);
+ const max = useSharedValue(maxMs);
// Animation values for controls
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
@@ -194,17 +167,13 @@ export const Controls: FC = ({
zIndex: 10,
}));
- // Initialize progress values
+ // Initialize progress values - MPV uses milliseconds
useEffect(() => {
if (item) {
- progress.value = isVlc
- ? ticksToMs(item?.UserData?.PlaybackPositionTicks)
- : item?.UserData?.PlaybackPositionTicks || 0;
- max.value = isVlc
- ? ticksToMs(item.RunTimeTicks || 0)
- : item.RunTimeTicks || 0;
+ progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
+ max.value = ticksToMs(item.RunTimeTicks || 0);
}
- }, [item, isVlc, progress, max]);
+ }, [item, progress, max]);
// Navigation hooks
const {
@@ -215,7 +184,6 @@ export const Controls: FC = ({
} = useVideoNavigation({
progress,
isPlaying,
- isVlc,
seek,
play,
});
@@ -225,7 +193,6 @@ export const Controls: FC = ({
progress,
max,
isSeeking,
- isVlc,
});
const toggleControls = useCallback(() => {
@@ -248,7 +215,6 @@ export const Controls: FC = ({
progress,
min,
max,
- isVlc,
showControls,
isPlaying,
seek,
@@ -273,7 +239,6 @@ export const Controls: FC = ({
progress,
isSeeking,
isPlaying,
- isVlc,
seek,
play,
pause,
@@ -302,9 +267,8 @@ export const Controls: FC = ({
: current.actual;
} else {
// When not scrubbing, only update if progress changed significantly (1 second)
- const progressUnit = isVlc
- ? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS
- : CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS;
+ // MPV uses milliseconds
+ const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
if (progressDiff >= progressUnit) {
effectiveProgress.value = current.actual;
@@ -325,22 +289,22 @@ export const Controls: FC = ({
currentTime,
seek,
play,
- isVlc,
offline,
api,
downloadedFiles,
);
- const { showSkipCreditButton, skipCredit } = useCreditSkipper(
- item.Id!,
- currentTime,
- seek,
- play,
- isVlc,
- offline,
- api,
- downloadedFiles,
- );
+ const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
+ useCreditSkipper(
+ item.Id!,
+ currentTime,
+ seek,
+ play,
+ offline,
+ api,
+ downloadedFiles,
+ maxMs,
+ );
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
@@ -359,12 +323,10 @@ export const Controls: FC = ({
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
- } = getDefaultPlaySettings(
- item,
- settings,
- previousIndexes,
- mediaSource ?? undefined,
- );
+ } = getDefaultPlaySettings(item, settings, {
+ indexes: previousIndexes,
+ source: mediaSource ?? undefined,
+ });
const queryParams = new URLSearchParams({
...(offline && { offline: "true" }),
@@ -377,8 +339,6 @@ export const Controls: FC = ({
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
- console.log("queryParams", queryParams);
-
router.replace(`player/direct-player?${queryParams}` as any);
},
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
@@ -469,6 +429,7 @@ export const Controls: FC = ({
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_CONSTANTS.TIMEOUT,
+ disabled: true,
});
const switchOnEpisodeMode = useCallback(() => {
@@ -479,11 +440,7 @@ export const Controls: FC = ({
}, [isPlaying, togglePlay]);
return (
-
+
{episodeView ? (
= ({
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
- getAudioTracks={getAudioTracks}
- getSubtitleTracks={getSubtitleTracks}
- setAudioTrack={setAudioTrack}
- setSubtitleTrack={setSubtitleTrack}
- setSubtitleURL={setSubtitleURL}
- aspectRatio={aspectRatio}
- scaleFactor={scaleFactor}
- setAspectRatio={setAspectRatio}
- setScaleFactor={setScaleFactor}
- setVideoAspectRatio={setVideoAspectRatio}
- setVideoScaleFactor={setVideoScaleFactor}
+ isZoomedToFill={isZoomedToFill}
+ onZoomToggle={onZoomToggle}
+ playbackSpeed={playbackSpeed}
+ setPlaybackSpeed={setPlaybackSpeed}
/>
= ({
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
- isVlc={isVlc}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
+ hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem}
@@ -582,6 +532,16 @@ export const Controls: FC = ({
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
)}
-
+
);
};
+
+const styles = StyleSheet.create({
+ controlsContainer: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+});
diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx
index a7d6c5bc..35a23535 100644
--- a/components/video-player/controls/EpisodeList.tsx
+++ b/components/video-player/controls/EpisodeList.tsx
@@ -2,11 +2,10 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { useGlobalSearchParams } from "expo-router";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
-import { SafeAreaView } from "react-native-safe-area-context";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -19,10 +18,16 @@ import {
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
-import type { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import {
+ getDownloadedEpisodesForSeason,
+ getDownloadedSeasonNumbers,
+} from "@/utils/downloads/offline-series";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { runtimeTicksToSeconds } from "@/utils/time";
+import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
type Props = {
item: BaseItemDto;
@@ -40,10 +45,9 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => {
const scrollToIndex = (index: number) => {
scrollViewRef.current?.scrollToIndex(index, 100);
};
- const { offline } = useGlobalSearchParams<{
- offline: string;
- }>();
- const isOffline = offline === "true";
+ const isOffline = useOfflineMode();
+ const { settings } = useSettings();
+ const insets = useSafeAreaInsets();
// Set the initial season index
useEffect(() => {
@@ -56,10 +60,6 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => {
}, []);
const { getDownloadedItems } = useDownload();
- const downloadedFiles = useMemo(
- () => getDownloadedItems(),
- [getDownloadedItems],
- );
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -68,15 +68,9 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => {
queryFn: async () => {
if (isOffline) {
if (!item.SeriesId) return [];
- const seriesEpisodes = downloadedFiles?.filter(
- (f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
- );
- const seasonNumbers = Array.from(
- new Set(
- seriesEpisodes
- ?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
- .filter(Boolean),
- ),
+ const seasonNumbers = getDownloadedSeasonNumbers(
+ getDownloadedItems(),
+ item.SeriesId,
);
// Create fake season objects
return seasonNumbers.map((seasonNumber) => ({
@@ -117,14 +111,12 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => {
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
queryFn: async () => {
if (isOffline) {
- if (!item.SeriesId) return [];
- return downloadedFiles
- ?.filter(
- (f: DownloadedItem) =>
- f.item.SeriesId === item.SeriesId &&
- f.item.ParentIndexNumber === seasonIndex,
- )
- .map((f: DownloadedItem) => f.item);
+ if (!item.SeriesId || typeof seasonIndex !== "number") return [];
+ return getDownloadedEpisodesForSeason(
+ getDownloadedItems(),
+ item.SeriesId,
+ seasonIndex,
+ );
}
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
@@ -153,6 +145,9 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => {
const queryClient = useQueryClient();
useEffect(() => {
+ // Don't prefetch when offline - data is already local
+ if (isOffline) return;
+
for (const e of episodes || []) {
queryClient.prefetchQuery({
queryKey: ["item", e.Id],
@@ -168,7 +163,7 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => {
staleTime: 60 * 5 * 1000,
});
}
- }, [episodes]);
+ }, [episodes, isOffline]);
// Scroll to the current item when episodes are fetched
useEffect(() => {
@@ -181,15 +176,24 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => {
}, [episodes, item.Id]);
return (
-
-
+
{seasons && seasons.length > 0 && !episodesLoading && episodes && (
= ({ item, close, goToItem }) => {
onPress={async () => {
close();
}}
- className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2 ml-auto'
+ className='aspect-square flex flex-col rounded-xl items-center justify-center p-2 ml-auto'
>
-
+
@@ -274,6 +278,6 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => {
showsHorizontalScrollIndicator={false}
/>
)}
-
+
);
};
diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx
index d8e4dcfe..11f3d360 100644
--- a/components/video-player/controls/HeaderControls.tsx
+++ b/components/video-player/controls/HeaderControls.tsx
@@ -3,25 +3,18 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
-import { useRouter } from "expo-router";
-import { type Dispatch, type FC, type SetStateAction } from "react";
-import {
- Platform,
- TouchableOpacity,
- useWindowDimensions,
- View,
-} from "react-native";
+import { type FC, useCallback, useState } from "react";
+import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
-import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
-import { ICON_SIZES } from "./constants";
-import { VideoProvider } from "./contexts/VideoContext";
+import { useOrientation } from "@/hooks/useOrientation";
+import { OrientationLock } from "@/packages/expo-screen-orientation";
+import { useSettings } from "@/utils/atoms/settings";
+import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView";
-import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
-import {
- type AspectRatio,
- AspectRatioSelector,
-} from "./VideoScalingModeSelector";
+import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
+import { ZoomToggle } from "./ZoomToggle";
interface HeaderControlsProps {
item: BaseItemDto;
@@ -34,17 +27,11 @@ interface HeaderControlsProps {
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null;
- getAudioTracks?: (() => Promise) | (() => any[]);
- getSubtitleTracks?: (() => Promise) | (() => any[]);
- setAudioTrack?: (index: number) => void;
- setSubtitleTrack?: (index: number) => void;
- setSubtitleURL?: (url: string, customName: string) => void;
- aspectRatio?: AspectRatio;
- scaleFactor?: ScaleFactor;
- setAspectRatio?: Dispatch>;
- setScaleFactor?: Dispatch>;
- setVideoAspectRatio?: (aspectRatio: string | null) => Promise;
- setVideoScaleFactor?: (scaleFactor: number) => Promise;
+ isZoomedToFill?: boolean;
+ onZoomToggle?: () => void;
+ // Playback speed props
+ playbackSpeed?: number;
+ setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
}
export const HeaderControls: FC = ({
@@ -58,90 +45,102 @@ export const HeaderControls: FC = ({
goToNextItem,
previousItem,
nextItem,
- getAudioTracks,
- getSubtitleTracks,
- setAudioTrack,
- setSubtitleTrack,
- setSubtitleURL,
- aspectRatio = "default",
- scaleFactor = 1.0,
- setAspectRatio,
- setScaleFactor,
- setVideoAspectRatio,
- setVideoScaleFactor,
+ isZoomedToFill = false,
+ onZoomToggle,
+ playbackSpeed = 1.0,
+ setPlaybackSpeed,
}) => {
const { settings } = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
- const { width: screenWidth } = useWindowDimensions();
const lightHapticFeedback = useHaptic("light");
-
- const handleAspectRatioChange = async (newRatio: AspectRatio) => {
- if (!setAspectRatio || !setVideoAspectRatio) return;
-
- setAspectRatio(newRatio);
- const aspectRatioString = newRatio === "default" ? null : newRatio;
- await setVideoAspectRatio(aspectRatioString);
- };
-
- const handleScaleFactorChange = async (newScale: ScaleFactor) => {
- if (!setScaleFactor || !setVideoScaleFactor) return;
-
- setScaleFactor(newScale);
- await setVideoScaleFactor(newScale);
- };
+ const { orientation, lockOrientation } = useOrientation();
+ const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
const onClose = async () => {
lightHapticFeedback();
router.back();
};
+ const toggleOrientation = useCallback(async () => {
+ if (isTogglingOrientation) return;
+
+ setIsTogglingOrientation(true);
+ lightHapticFeedback();
+
+ try {
+ const isPortrait =
+ orientation === OrientationLock.PORTRAIT_UP ||
+ orientation === OrientationLock.PORTRAIT_DOWN;
+
+ await lockOrientation(
+ isPortrait ? OrientationLock.LANDSCAPE : OrientationLock.PORTRAIT_UP,
+ );
+ } finally {
+ setIsTogglingOrientation(false);
+ }
+ }, [
+ orientation,
+ lockOrientation,
+ isTogglingOrientation,
+ lightHapticFeedback,
+ ]);
+
return (
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
-
-
-
-
-
+
+
+
)}
- {!Platform.isTV &&
- (settings.defaultPlayer === VideoPlayer.VLC_4 ||
- Platform.OS === "android") && (
-
-
-
- )}
+ {!Platform.isTV && (
+
+
+
+ )}
+ {!Platform.isTV && startPictureInPicture && (
+
+
+
+ )}
{item?.Type === "Episode" && (
= ({
/>
)}
-
- {})}
+ disabled={!onZoomToggle}
/>
void;
- disabled?: boolean;
-}
-
-interface ScaleFactorOption {
- id: ScaleFactor;
- label: string;
- description: string;
-}
-
-const SCALE_FACTOR_OPTIONS: ScaleFactorOption[] = [
- {
- id: 1.0,
- label: "1.0x",
- description: "Original size",
- },
- {
- id: 1.1,
- label: "1.1x",
- description: "10% larger",
- },
- {
- id: 1.2,
- label: "1.2x",
- description: "20% larger",
- },
- {
- id: 1.3,
- label: "1.3x",
- description: "30% larger",
- },
- {
- id: 1.4,
- label: "1.4x",
- description: "40% larger",
- },
- {
- id: 1.5,
- label: "1.5x",
- description: "50% larger",
- },
- {
- id: 1.6,
- label: "1.6x",
- description: "60% larger",
- },
- {
- id: 1.7,
- label: "1.7x",
- description: "70% larger",
- },
- {
- id: 1.8,
- label: "1.8x",
- description: "80% larger",
- },
- {
- id: 1.9,
- label: "1.9x",
- description: "90% larger",
- },
- {
- id: 2.0,
- label: "2.0x",
- description: "Double size",
- },
-];
-
-export const ScaleFactorSelector: React.FC = ({
- currentScale,
- onScaleChange,
- disabled = false,
-}) => {
- const lightHapticFeedback = useHaptic("light");
-
- const handleScaleSelect = (scale: ScaleFactor) => {
- onScaleChange(scale);
- lightHapticFeedback();
- };
-
- const optionGroups = useMemo(() => {
- return [
- {
- options: SCALE_FACTOR_OPTIONS.map((option) => ({
- type: "radio" as const,
- label: option.label,
- value: option.id,
- selected: option.id === currentScale,
- onPress: () => handleScaleSelect(option.id),
- disabled,
- })),
- },
- ];
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [currentScale, disabled]);
-
- const trigger = useMemo(
- () => (
-
-
-
- ),
- [disabled],
- );
-
- // Hide on TV platforms
- if (Platform.isTV) return null;
-
- return (
-
- );
-};
diff --git a/components/video-player/controls/TimeDisplay.tsx b/components/video-player/controls/TimeDisplay.tsx
index 37ba755b..3f8cfd69 100644
--- a/components/video-player/controls/TimeDisplay.tsx
+++ b/components/video-player/controls/TimeDisplay.tsx
@@ -6,18 +6,20 @@ import { formatTimeString } from "@/utils/time";
interface TimeDisplayProps {
currentTime: number;
remainingTime: number;
- isVlc: boolean;
}
+/**
+ * Displays current time and remaining time.
+ * MPV player uses milliseconds for time values.
+ */
export const TimeDisplay: FC = ({
currentTime,
remainingTime,
- isVlc,
}) => {
const getFinishTime = () => {
const now = new Date();
- const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
- const finishTime = new Date(now.getTime() + remainingMs);
+ // remainingTime is in ms
+ const finishTime = new Date(now.getTime() + remainingTime);
return finishTime.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
@@ -28,11 +30,11 @@ export const TimeDisplay: FC = ({
return (
- {formatTimeString(currentTime, isVlc ? "ms" : "s")}
+ {formatTimeString(currentTime, "ms")}
- -{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
+ -{formatTimeString(remainingTime, "ms")}
ends at {getFinishTime()}
diff --git a/components/video-player/controls/VideoScalingModeSelector.tsx b/components/video-player/controls/VideoScalingModeSelector.tsx
deleted file mode 100644
index 2608f04d..00000000
--- a/components/video-player/controls/VideoScalingModeSelector.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import React, { useMemo } from "react";
-import { Platform, View } from "react-native";
-import {
- type OptionGroup,
- PlatformDropdown,
-} from "@/components/PlatformDropdown";
-import { useHaptic } from "@/hooks/useHaptic";
-
-export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
-
-interface AspectRatioSelectorProps {
- currentRatio: AspectRatio;
- onRatioChange: (ratio: AspectRatio) => void;
- disabled?: boolean;
-}
-
-interface AspectRatioOption {
- id: AspectRatio;
- label: string;
- description: string;
-}
-
-const ASPECT_RATIO_OPTIONS: AspectRatioOption[] = [
- {
- id: "default",
- label: "Original",
- description: "Use video's original aspect ratio",
- },
- {
- id: "16:9",
- label: "16:9",
- description: "Widescreen (most common)",
- },
- {
- id: "4:3",
- label: "4:3",
- description: "Traditional TV format",
- },
- {
- id: "1:1",
- label: "1:1",
- description: "Square format",
- },
- {
- id: "21:9",
- label: "21:9",
- description: "Ultra-wide cinematic",
- },
-];
-
-export const AspectRatioSelector: React.FC = ({
- currentRatio,
- onRatioChange,
- disabled = false,
-}) => {
- const lightHapticFeedback = useHaptic("light");
-
- const handleRatioSelect = (ratio: AspectRatio) => {
- onRatioChange(ratio);
- lightHapticFeedback();
- };
-
- const optionGroups = useMemo(() => {
- 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(
- () => (
-
-
-
- ),
- [disabled],
- );
-
- // Hide on TV platforms
- if (Platform.isTV) return null;
-
- return (
-
- );
-};
diff --git a/components/video-player/controls/ZoomToggle.tsx b/components/video-player/controls/ZoomToggle.tsx
new file mode 100644
index 00000000..ede70401
--- /dev/null
+++ b/components/video-player/controls/ZoomToggle.tsx
@@ -0,0 +1,44 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Platform, TouchableOpacity, View } from "react-native";
+import { useHaptic } from "@/hooks/useHaptic";
+import { ICON_SIZES } from "./constants";
+
+interface ZoomToggleProps {
+ isZoomedToFill: boolean;
+ onToggle: () => void;
+ disabled?: boolean;
+}
+
+export const ZoomToggle: React.FC = ({
+ isZoomedToFill,
+ onToggle,
+ disabled = false,
+}) => {
+ const lightHapticFeedback = useHaptic("light");
+
+ const handlePress = () => {
+ if (disabled) return;
+ lightHapticFeedback();
+ onToggle();
+ };
+
+ // Hide on TV platforms
+ if (Platform.isTV) return null;
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts
index afe57070..dc735cf9 100644
--- a/components/video-player/controls/constants.ts
+++ b/components/video-player/controls/constants.ts
@@ -15,3 +15,7 @@ export const ICON_SIZES = {
HEADER: 24,
CENTER: 50,
} as const;
+
+export const HEADER_LAYOUT = {
+ CONTAINER_PADDING: 8, // p-2 = 8px (matches HeaderControls)
+} as const;
diff --git a/components/video-player/controls/contexts/ControlContext.tsx b/components/video-player/controls/contexts/ControlContext.tsx
deleted file mode 100644
index c13211c9..00000000
--- a/components/video-player/controls/contexts/ControlContext.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import type {
- BaseItemDto,
- MediaSourceInfo,
-} from "@jellyfin/sdk/lib/generated-client";
-import type React from "react";
-import { createContext, type ReactNode, useContext } from "react";
-
-interface ControlContextProps {
- item: BaseItemDto;
- mediaSource: MediaSourceInfo | null | undefined;
- isVideoLoaded: boolean | undefined;
-}
-
-const ControlContext = createContext(
- undefined,
-);
-
-interface ControlProviderProps {
- children: ReactNode;
- item: BaseItemDto;
- mediaSource: MediaSourceInfo | null | undefined;
- isVideoLoaded: boolean | undefined;
-}
-
-export const ControlProvider: React.FC = ({
- children,
- item,
- mediaSource,
- isVideoLoaded,
-}) => {
- return (
-
- {children}
-
- );
-};
-
-export const useControlContext = () => {
- const context = useContext(ControlContext);
- if (context === undefined) {
- throw new Error("useControlContext must be used within a ControlProvider");
- }
- return context;
-};
diff --git a/components/video-player/controls/contexts/PlayerContext.tsx b/components/video-player/controls/contexts/PlayerContext.tsx
new file mode 100644
index 00000000..e3c87b78
--- /dev/null
+++ b/components/video-player/controls/contexts/PlayerContext.tsx
@@ -0,0 +1,118 @@
+import type {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client";
+import React, {
+ createContext,
+ type MutableRefObject,
+ type ReactNode,
+ useContext,
+ useMemo,
+} from "react";
+import type { MpvPlayerViewRef } from "@/modules";
+import type { DownloadedItem } from "@/providers/Downloads/types";
+
+interface PlayerContextProps {
+ playerRef: MutableRefObject;
+ item: BaseItemDto;
+ mediaSource: MediaSourceInfo | null | undefined;
+ isVideoLoaded: boolean;
+ tracksReady: boolean;
+ downloadedItem: DownloadedItem | null;
+}
+
+const PlayerContext = createContext(undefined);
+
+interface PlayerProviderProps {
+ children: ReactNode;
+ playerRef: MutableRefObject;
+ item: BaseItemDto;
+ mediaSource: MediaSourceInfo | null | undefined;
+ isVideoLoaded: boolean;
+ tracksReady: boolean;
+ downloadedItem?: DownloadedItem | null;
+}
+
+export const PlayerProvider: React.FC = ({
+ children,
+ playerRef,
+ item,
+ mediaSource,
+ isVideoLoaded,
+ tracksReady,
+ downloadedItem = null,
+}) => {
+ const value = useMemo(
+ () => ({
+ playerRef,
+ item,
+ mediaSource,
+ isVideoLoaded,
+ tracksReady,
+ downloadedItem,
+ }),
+ [playerRef, item, mediaSource, isVideoLoaded, tracksReady, downloadedItem],
+ );
+
+ return (
+ {children}
+ );
+};
+
+// Core context hook
+export const usePlayerContext = () => {
+ const context = useContext(PlayerContext);
+ if (!context)
+ throw new Error("usePlayerContext must be used within PlayerProvider");
+ return context;
+};
+
+// Player controls hook - MPV player only
+export const usePlayerControls = () => {
+ const { playerRef } = usePlayerContext();
+
+ return {
+ // Subtitle controls
+ getSubtitleTracks: async () => {
+ return playerRef.current?.getSubtitleTracks?.() ?? null;
+ },
+ setSubtitleTrack: (trackId: number) => {
+ playerRef.current?.setSubtitleTrack?.(trackId);
+ },
+ disableSubtitles: () => {
+ playerRef.current?.disableSubtitles?.();
+ },
+ addSubtitleFile: (url: string, select = true) => {
+ playerRef.current?.addSubtitleFile?.(url, select);
+ },
+
+ // Audio controls
+ getAudioTracks: async () => {
+ return playerRef.current?.getAudioTracks?.() ?? null;
+ },
+ setAudioTrack: (trackId: number) => {
+ playerRef.current?.setAudioTrack?.(trackId);
+ },
+
+ // Playback controls
+ play: () => playerRef.current?.play?.(),
+ pause: () => playerRef.current?.pause?.(),
+ seekTo: (position: number) => playerRef.current?.seekTo?.(position),
+ seekBy: (offset: number) => playerRef.current?.seekBy?.(offset),
+ setSpeed: (speed: number) => playerRef.current?.setSpeed?.(speed),
+
+ // Subtitle positioning
+ setSubtitleScale: (scale: number) =>
+ playerRef.current?.setSubtitleScale?.(scale),
+ setSubtitlePosition: (position: number) =>
+ playerRef.current?.setSubtitlePosition?.(position),
+ setSubtitleMarginY: (margin: number) =>
+ playerRef.current?.setSubtitleMarginY?.(margin),
+ setSubtitleFontSize: (size: number) =>
+ playerRef.current?.setSubtitleFontSize?.(size),
+
+ // PiP
+ startPictureInPicture: () => playerRef.current?.startPictureInPicture?.(),
+ stopPictureInPicture: () => playerRef.current?.stopPictureInPicture?.(),
+ };
+};
diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx
index ed7fa1e0..ec9ca995 100644
--- a/components/video-player/controls/contexts/VideoContext.tsx
+++ b/components/video-player/controls/contexts/VideoContext.tsx
@@ -1,5 +1,53 @@
+/**
+ * VideoContext.tsx
+ *
+ * Manages subtitle and audio track state for the video player UI.
+ *
+ * ============================================================================
+ * ARCHITECTURE
+ * ============================================================================
+ *
+ * - Jellyfin is source of truth for subtitle list (embedded + external)
+ * - MPV only knows about:
+ * - Embedded subs it finds in the video stream
+ * - External subs we explicitly add via addSubtitleFile()
+ * - UI shows Jellyfin's complete list
+ * - On selection: either select embedded track or load external URL
+ *
+ * ============================================================================
+ * INDEX TYPES
+ * ============================================================================
+ *
+ * 1. SERVER INDEX (sub.Index / track.index)
+ * - Jellyfin's server-side stream index
+ * - Used to report playback state to Jellyfin server
+ * - Value of -1 means disabled/none
+ *
+ * 2. MPV INDEX (track.mpvIndex)
+ * - MPV's internal track ID
+ * - MPV orders tracks as: [all embedded, then all external]
+ * - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
+ * - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
+ *
+ * ============================================================================
+ * SUBTITLE HANDLING
+ * ============================================================================
+ *
+ * Embedded (DeliveryMethod.Embed):
+ * - Already in MPV's track list
+ * - Select via setSubtitleTrack(mpvId)
+ *
+ * External (DeliveryMethod.External):
+ * - Loaded into MPV on video start
+ * - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
+ *
+ * Image-based during transcoding:
+ * - Burned into video by Jellyfin, not in MPV
+ * - Requires replacePlayer() to change
+ */
+
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
-import { router, useLocalSearchParams } from "expo-router";
+import { useLocalSearchParams } from "expo-router";
import type React from "react";
import {
createContext,
@@ -9,52 +57,30 @@ import {
useMemo,
useState,
} from "react";
-import type { TrackInfo } from "@/modules/VlcPlayer.types";
+import useRouter from "@/hooks/useAppRouter";
+import type { MpvAudioTrack } from "@/modules";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types";
-import { useControlContext } from "./ControlContext";
+import { usePlayerContext, usePlayerControls } from "./PlayerContext";
interface VideoContextProps {
- audioTracks: Track[] | null;
subtitleTracks: Track[] | null;
- setAudioTrack: ((index: number) => void) | undefined;
- setSubtitleTrack: ((index: number) => void) | undefined;
- setSubtitleURL: ((url: string, customName: string) => void) | undefined;
+ audioTracks: Track[] | null;
}
const VideoContext = createContext(undefined);
-interface VideoProviderProps {
- children: ReactNode;
- getAudioTracks:
- | (() => Promise)
- | (() => TrackInfo[])
- | undefined;
- getSubtitleTracks:
- | (() => Promise)
- | (() => TrackInfo[])
- | undefined;
- setAudioTrack: ((index: number) => void) | undefined;
- setSubtitleTrack: ((index: number) => void) | undefined;
- setSubtitleURL: ((url: string, customName: string) => void) | undefined;
-}
-
-export const VideoProvider: React.FC = ({
+export const VideoProvider: React.FC<{ children: ReactNode }> = ({
children,
- getSubtitleTracks,
- getAudioTracks,
- setSubtitleTrack,
- setSubtitleURL,
- setAudioTrack,
}) => {
- const [audioTracks, setAudioTracks] = useState