Compare commits
116 Commits
renovate/r
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
715daf1635 | ||
|
|
44caf4b1ff | ||
|
|
92c70fadd1 | ||
|
|
f637367b82 | ||
|
|
715764cef8 | ||
|
|
36d6686258 | ||
|
|
dca7cc99f2 | ||
|
|
875a017e8c | ||
|
|
0c6c20f563 | ||
|
|
2c9906377d | ||
|
|
d5f7a18fe5 | ||
|
|
4606b9718e | ||
|
|
c2d61654b0 | ||
|
|
2c6938c739 | ||
|
|
1f454c0f12 | ||
|
|
c215fda973 | ||
|
|
a852e2e769 | ||
|
|
29873e08d7 | ||
|
|
5ce5cc2d99 | ||
|
|
ae5a71ff29 | ||
|
|
0e3e8b8016 | ||
|
|
d07a521f60 | ||
|
|
566ff485fb | ||
|
|
3a4042efd5 | ||
|
|
fb9b4b6f2d | ||
|
|
1b80db678e | ||
|
|
093fcc6187 | ||
|
|
26e8489384 | ||
|
|
02a65059b9 | ||
|
|
be2fd53f31 | ||
|
|
be92b5d75e | ||
|
|
3f882ecade | ||
|
|
4b7007386f | ||
|
|
d2790f4997 | ||
|
|
096670a0c3 | ||
|
|
aa6b441dd1 | ||
|
|
d8512897ad | ||
|
|
11b6f16cd3 | ||
|
|
506d8b14dc | ||
|
|
a8acdf4299 | ||
|
|
2a9f4c2885 | ||
|
|
0353a718f3 | ||
|
|
e3b4952c60 | ||
|
|
5f44540b6f | ||
|
|
4705c9f4f9 | ||
|
|
2b36d4bc76 | ||
|
|
f4445c4152 | ||
|
|
16a236393d | ||
|
|
eeb4ef3008 | ||
|
|
a173db9180 | ||
|
|
a8c07a31d3 | ||
|
|
493df28b8d | ||
|
|
749473c1e8 | ||
|
|
f8d1fad6d5 | ||
|
|
81af2afef8 | ||
|
|
9ef79ef364 | ||
|
|
83babc2687 | ||
|
|
f9a3a1f9f6 | ||
|
|
0f076d197f | ||
|
|
d28b5411d5 | ||
|
|
1da49d29d7 | ||
|
|
7af4b913d7 | ||
|
|
a667723d93 | ||
|
|
94bfa26041 | ||
|
|
d545ca3584 | ||
|
|
773701d0c1 | ||
|
|
a3f7d0c275 | ||
|
|
5b7ded08cc | ||
|
|
60dd00ad7e | ||
|
|
ec653cae15 | ||
|
|
18bc45ea0a | ||
|
|
ebb33854d7 | ||
|
|
9efa2bbaa2 | ||
|
|
c515d037cf | ||
|
|
ee3a288fa0 | ||
|
|
c0171aa656 | ||
|
|
41d3e61261 | ||
|
|
8f74c3edc7 | ||
|
|
56ffec3173 | ||
|
|
9509a427c8 | ||
|
|
cfcfb486bf | ||
|
|
407ea69425 | ||
|
|
e1e91ea1a6 | ||
|
|
e7ea8a2c3b | ||
|
|
9f1791ce93 | ||
|
|
38cb7068ef | ||
|
|
cc154f0c16 | ||
|
|
866aa44277 | ||
|
|
ff3f88c53b | ||
|
|
3fd76b1356 | ||
|
|
a86df6c46b | ||
|
|
bdd284b9a6 | ||
|
|
fff7d4459f | ||
|
|
b85549016d | ||
|
|
6c35608404 | ||
|
|
74e3465a84 | ||
|
|
be32d933bb | ||
|
|
db89295d9b | ||
|
|
8d90fe3a8b | ||
|
|
4880392197 | ||
|
|
e10a99cc48 | ||
|
|
55b897883b | ||
|
|
fe26a74451 | ||
|
|
4cdbab7d19 | ||
|
|
3e695def23 | ||
|
|
15e4c18d54 | ||
|
|
87169480a1 | ||
|
|
bd9467b09e | ||
|
|
6216e7fdb7 | ||
|
|
6d2e897c9f | ||
|
|
ad5148daad | ||
|
|
c1e12d5898 | ||
|
|
7416c8297a | ||
|
|
9727bec7ab | ||
|
|
6ba767a848 | ||
|
|
4ad103acb6 |
@@ -24,4 +24,20 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
|
|||||||
|
|
||||||
- **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)_
|
- **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)_
|
- **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)_
|
||||||
|
|
||||||
|
- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_
|
||||||
|
|
||||||
|
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
|
||||||
|
|
||||||
|
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
|
||||||
|
|
||||||
|
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
|
||||||
|
|
||||||
|
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
||||||
2
.github/workflows/artifact-comment.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🔍 Get PR and Artifacts
|
- name: 🔍 Get PR and Artifacts
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
// Check if we're running from a fork (more precise detection)
|
// Check if we're running from a fork (more precise detection)
|
||||||
|
|||||||
46
.github/workflows/build-apps.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -41,12 +41,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -124,12 +124,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -156,7 +156,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -171,7 +171,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -195,12 +195,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -216,12 +216,12 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.2"
|
xcode-version: "26.2"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -236,7 +236,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||||
path: build-*.ipa
|
path: build-*.ipa
|
||||||
@@ -251,7 +251,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -259,12 +259,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -280,7 +280,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.2"
|
xcode-version: "26.2"
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
|
||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
|
|||||||
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -27,12 +27,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|||||||
4
.github/workflows/crowdin.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
18
.github/workflows/linting.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
delete: true
|
delete: true
|
||||||
@@ -51,13 +51,13 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
@@ -69,14 +69,14 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
@@ -100,19 +100,19 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
|
|||||||
8
.github/workflows/update-issue-form.yml
vendored
@@ -18,17 +18,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: 🔍 Extract minor version from app.json
|
- name: 🔍 Extract minor version from app.json
|
||||||
id: minor
|
id: minor
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
uses: actions/github-script@main
|
||||||
with:
|
with:
|
||||||
result-encoding: string
|
result-encoding: string
|
||||||
script: |
|
script: |
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
dry_run: no-push
|
dry_run: no-push
|
||||||
|
|
||||||
- name: 📬 Commit and create pull request
|
- name: 📬 Commit and create pull request
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci-update-bug-report
|
branch: ci-update-bug-report
|
||||||
|
|||||||
5
.gitignore
vendored
@@ -50,8 +50,6 @@ npm-debug.*
|
|||||||
.idea/
|
.idea/
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
.cursor/
|
.cursor/
|
||||||
.claude/
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# Environment and Configuration
|
# Environment and Configuration
|
||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
@@ -72,4 +70,5 @@ modules/background-downloader/android/build/*
|
|||||||
/modules/mpv-player/android/build
|
/modules/mpv-player/android/build
|
||||||
|
|
||||||
# ios:unsigned-build Artifacts
|
# ios:unsigned-build Artifacts
|
||||||
build/
|
build/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
210
CLAUDE.md
@@ -134,3 +134,213 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
- TV version uses `:tv` suffix for scripts
|
- TV version uses `:tv` suffix for scripts
|
||||||
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
||||||
- Some features disabled on TV (e.g., notifications, Chromecast)
|
- Some features disabled on TV (e.g., notifications, Chromecast)
|
||||||
|
- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays.
|
||||||
|
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
|
||||||
|
- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency.
|
||||||
|
- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
|
||||||
|
- **TV Modals**: Never use overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference.
|
||||||
|
|
||||||
|
### TV Component Rendering Pattern
|
||||||
|
|
||||||
|
**IMPORTANT**: The `.tv.tsx` file suffix only works for **pages** in the `app/` directory (resolved by Expo Router). It does NOT work for components - Metro bundler doesn't resolve platform-specific suffixes for component imports.
|
||||||
|
|
||||||
|
**Pattern for TV-specific components**:
|
||||||
|
```typescript
|
||||||
|
// In page file (e.g., app/login.tsx)
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { Login } from "@/components/login/Login";
|
||||||
|
import { TVLogin } from "@/components/login/TVLogin";
|
||||||
|
|
||||||
|
const LoginPage: React.FC = () => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVLogin />;
|
||||||
|
}
|
||||||
|
return <Login />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
|
||||||
|
- Use `Platform.isTV` to conditionally render the appropriate component
|
||||||
|
- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling
|
||||||
|
|
||||||
|
### TV Option Selector Pattern (Dropdowns/Multi-select)
|
||||||
|
|
||||||
|
For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because:
|
||||||
|
- Horizontal scrolling is natural for TV remotes (left/right D-pad)
|
||||||
|
- Bottom sheet takes minimal screen space
|
||||||
|
- Focus-based navigation works reliably
|
||||||
|
|
||||||
|
**Key implementation details:**
|
||||||
|
|
||||||
|
1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain. Use an absolutely positioned `View` overlay instead:
|
||||||
|
```typescript
|
||||||
|
<View style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
<BlurView intensity={80} tint="dark" style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24 }}>
|
||||||
|
{/* Content */}
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Horizontal ScrollView with focusable cards**:
|
||||||
|
```typescript
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 48, paddingVertical: 10, gap: 12 }}
|
||||||
|
>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={index}
|
||||||
|
hasTVPreferredFocus={index === selectedIndex}
|
||||||
|
onPress={() => { onSelect(option.value); onClose(); }}
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`:
|
||||||
|
```typescript
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => { setFocused(true); animateTo(1.05); }}
|
||||||
|
onBlur={() => { setFocused(false); animateTo(1); }}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View style={{ transform: [{ scale }], backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)" }}>
|
||||||
|
<Text style={{ color: focused ? "#000" : "#fff" }}>{label}</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip.
|
||||||
|
|
||||||
|
**Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`
|
||||||
|
|
||||||
|
### TV Focus Management for Overlays/Modals
|
||||||
|
|
||||||
|
**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation.
|
||||||
|
|
||||||
|
**Solution**: Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Track modal state
|
||||||
|
const [openModal, setOpenModal] = useState<ModalType | null>(null);
|
||||||
|
const isModalOpen = openModal !== null;
|
||||||
|
|
||||||
|
// 2. Each focusable component accepts disabled prop
|
||||||
|
const TVFocusableButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, disabled }) => (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
hasTVPreferredFocus={isFirst && !disabled}
|
||||||
|
>
|
||||||
|
{/* content */}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Pass disabled to all background components when modal is open
|
||||||
|
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
|
||||||
|
|
||||||
|
### TV Focus Flickering Between Zones (Lists with Headers)
|
||||||
|
|
||||||
|
When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
|
||||||
|
1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check:
|
||||||
|
```typescript
|
||||||
|
{Platform.isTV ? (
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FlashList data={items} renderItem={renderItem} />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.
|
||||||
|
|
||||||
|
3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - only first filter button has preferred focus
|
||||||
|
<TVFilterButton hasTVPreferredFocus={index === 0} />
|
||||||
|
<TVFocusablePoster /> // No hasTVPreferredFocus
|
||||||
|
|
||||||
|
// ❌ Bad - both compete for focus
|
||||||
|
<TVFilterButton hasTVPreferredFocus />
|
||||||
|
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
|
||||||
|
```typescript
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Filter bar - separate from list */}
|
||||||
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||||
|
<TVFilterButton />
|
||||||
|
<TVFilterButton />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<FlatList data={items} renderItem={renderTVItem} />
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
|
||||||
|
|
||||||
|
**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.
|
||||||
|
|
||||||
|
### TV Focus Guide Navigation (Non-Adjacent Sections)
|
||||||
|
|
||||||
|
When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use `TVFocusGuideView` with the `destinations` prop:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Track destination with useState (NOT useRef - won't trigger re-renders)
|
||||||
|
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
// 2. Place invisible focus guide between sections
|
||||||
|
{firstCardRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[firstCardRef]}
|
||||||
|
style={{ height: 1, width: "100%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
// 3. Target component must use forwardRef
|
||||||
|
const MyCard = React.forwardRef<View, Props>(({ ... }, ref) => (
|
||||||
|
<Pressable ref={ref} ...>
|
||||||
|
...
|
||||||
|
</Pressable>
|
||||||
|
));
|
||||||
|
|
||||||
|
// 4. Pass state setter as callback ref to first item
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<MyCard
|
||||||
|
ref={index === 0 ? setFirstCardRef : undefined}
|
||||||
|
...
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For detailed documentation and bidirectional navigation patterns, see [docs/tv-focus-guide.md](docs/tv-focus-guide.md)**
|
||||||
|
|
||||||
|
**Reference implementation**: See `components/ItemContent.tv.tsx` for bidirectional focus navigation between playback options and cast list.
|
||||||
|
|||||||
23
app.json
@@ -23,7 +23,8 @@
|
|||||||
},
|
},
|
||||||
"UISupportsTrueScreenSizeOnMac": true,
|
"UISupportsTrueScreenSizeOnMac": true,
|
||||||
"UIFileSharingEnabled": true,
|
"UIFileSharingEnabled": true,
|
||||||
"LSSupportsOpeningDocumentsInPlace": true
|
"LSSupportsOpeningDocumentsInPlace": true,
|
||||||
|
"AVInitialRouteSharingPolicy": "LongFormAudio"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
@@ -55,7 +56,23 @@
|
|||||||
"googleServicesFile": "./google-services.json"
|
"googleServicesFile": "./google-services.json"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@react-native-tvos/config-tv",
|
[
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
|
{
|
||||||
|
"appleTVImages": {
|
||||||
|
"icon": "./assets/images/icon-tvos.png",
|
||||||
|
"iconSmall": "./assets/images/icon-tvos-small.png",
|
||||||
|
"iconSmall2x": "./assets/images/icon-tvos-small-2x.png",
|
||||||
|
"topShelf": "./assets/images/icon-tvos-topshelf.png",
|
||||||
|
"topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png",
|
||||||
|
"topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png",
|
||||||
|
"topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png"
|
||||||
|
},
|
||||||
|
"infoPlist": {
|
||||||
|
"UIAppSupportsHDR": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.js",
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
@@ -121,6 +138,8 @@
|
|||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.js"],
|
||||||
|
["./plugins/withTVOSAppIcon.js"],
|
||||||
|
["./plugins/withTVXcodeEnv.js"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: Platform.OS !== "ios",
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.custom_links"),
|
headerTitle: t("tabs.custom_links"),
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
|||||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Favorites } from "@/components/home/Favorites";
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
|
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
|
||||||
export default function favorites() {
|
export default function favorites() {
|
||||||
@@ -15,6 +16,10 @@ export default function favorites() {
|
|||||||
}, []);
|
}, []);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVFavorites />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/index'
|
name='downloads/index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
@@ -62,7 +62,7 @@ export default function IndexLayout() {
|
|||||||
name='sessions/index'
|
name='sessions/index'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.sessions.title"),
|
title: t("home.sessions.title"),
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -81,6 +81,7 @@ export default function IndexLayout() {
|
|||||||
name='settings'
|
name='settings'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -99,6 +100,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/playback-controls/page'
|
name='settings/playback-controls/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.playback_controls.title"),
|
title: t("home.settings.playback_controls.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -117,6 +119,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/audio-subtitles/page'
|
name='settings/audio-subtitles/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.audio_subtitles.title"),
|
title: t("home.settings.audio_subtitles.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -135,6 +138,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/appearance/page'
|
name='settings/appearance/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.appearance.title"),
|
title: t("home.settings.appearance.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -153,6 +157,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/music/page'
|
name='settings/music/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.music.title"),
|
title: t("home.settings.music.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -171,6 +176,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/appearance/hide-libraries/page'
|
name='settings/appearance/hide-libraries/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.other.hide_libraries"),
|
title: t("home.settings.other.hide_libraries"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -189,6 +195,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/page'
|
name='settings/plugins/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.plugins.plugins_title"),
|
title: t("home.settings.plugins.plugins_title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -207,6 +214,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/marlin-search/page'
|
name='settings/plugins/marlin-search/page'
|
||||||
options={{
|
options={{
|
||||||
title: "Marlin Search",
|
title: "Marlin Search",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -225,6 +233,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/jellyseerr/page'
|
name='settings/plugins/jellyseerr/page'
|
||||||
options={{
|
options={{
|
||||||
title: "Jellyseerr",
|
title: "Jellyseerr",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -243,6 +252,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/streamystats/page'
|
name='settings/plugins/streamystats/page'
|
||||||
options={{
|
options={{
|
||||||
title: "Streamystats",
|
title: "Streamystats",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -261,6 +271,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/kefinTweaks/page'
|
name='settings/plugins/kefinTweaks/page'
|
||||||
options={{
|
options={{
|
||||||
title: "KefinTweaks",
|
title: "KefinTweaks",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -279,6 +290,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/intro/page'
|
name='settings/intro/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.intro.title"),
|
title: t("home.settings.intro.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -297,6 +309,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/logs/page'
|
name='settings/logs/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.logs.logs_title"),
|
title: t("home.settings.logs.logs_title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -315,6 +328,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/network/page'
|
name='settings/network/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.network.title"),
|
title: t("home.settings.network.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -341,7 +355,7 @@ export default function IndexLayout() {
|
|||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
),
|
),
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Home } from "../../../../components/home/Home";
|
import { Home } from "../../../../components/home/Home";
|
||||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const { settings } = useSettings();
|
|
||||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
|
||||||
|
|
||||||
if (showLargeHomeCarousel) {
|
|
||||||
return <HomeWithCarousel />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Home />;
|
return <Home />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import { UserInfo } from "@/components/settings/UserInfo";
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function settings() {
|
// TV-specific settings component
|
||||||
|
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
||||||
|
|
||||||
|
// Mobile settings component
|
||||||
|
function SettingsMobile() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [_user] = useAtom(userAtom);
|
const [_user] = useAtom(userAtom);
|
||||||
@@ -104,8 +108,17 @@ export default function settings() {
|
|||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!Platform.isTV && <StorageSettings />}
|
<StorageSettings />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function settings() {
|
||||||
|
// Use TV settings component on TV platforms
|
||||||
|
if (Platform.isTV && SettingsTV) {
|
||||||
|
return <SettingsTV />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SettingsMobile />;
|
||||||
|
}
|
||||||
|
|||||||
449
app/(auth)/(tabs)/(home)/settings.tv.tsx
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import type { TVOptionItem } from "@/components/tv";
|
||||||
|
import {
|
||||||
|
TVLogoutButton,
|
||||||
|
TVSectionHeader,
|
||||||
|
TVSettingsOptionButton,
|
||||||
|
TVSettingsRow,
|
||||||
|
TVSettingsStepper,
|
||||||
|
TVSettingsTextInput,
|
||||||
|
TVSettingsToggle,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
AudioTranscodeMode,
|
||||||
|
TVTypographyScale,
|
||||||
|
useSettings,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function SettingsTV() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { logout } = useJellyfin();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
|
settings.openSubtitlesApiKey || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentAudioTranscode =
|
||||||
|
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
|
||||||
|
const currentSubtitleMode =
|
||||||
|
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
||||||
|
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
||||||
|
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||||
|
const currentTypographyScale =
|
||||||
|
settings.tvTypographyScale || TVTypographyScale.Default;
|
||||||
|
|
||||||
|
// Audio transcoding options
|
||||||
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.auto"),
|
||||||
|
value: AudioTranscodeMode.Auto,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.Auto,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.stereo"),
|
||||||
|
value: AudioTranscodeMode.ForceStereo,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.ForceStereo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.5_1"),
|
||||||
|
value: AudioTranscodeMode.Allow51,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.Allow51,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.passthrough"),
|
||||||
|
value: AudioTranscodeMode.AllowAll,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.AllowAll,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentAudioTranscode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subtitle mode options
|
||||||
|
const subtitleModeOptions: TVOptionItem<SubtitlePlaybackMode>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.Default"),
|
||||||
|
value: SubtitlePlaybackMode.Default,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.Default,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.Smart"),
|
||||||
|
value: SubtitlePlaybackMode.Smart,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.Smart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.OnlyForced"),
|
||||||
|
value: SubtitlePlaybackMode.OnlyForced,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.OnlyForced,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.Always"),
|
||||||
|
value: SubtitlePlaybackMode.Always,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.Always,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.None"),
|
||||||
|
value: SubtitlePlaybackMode.None,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentSubtitleMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// MPV alignment options
|
||||||
|
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Left", value: "left", selected: currentAlignX === "left" },
|
||||||
|
{
|
||||||
|
label: "Center",
|
||||||
|
value: "center",
|
||||||
|
selected: currentAlignX === "center",
|
||||||
|
},
|
||||||
|
{ label: "Right", value: "right", selected: currentAlignX === "right" },
|
||||||
|
],
|
||||||
|
[currentAlignX],
|
||||||
|
);
|
||||||
|
|
||||||
|
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Top", value: "top", selected: currentAlignY === "top" },
|
||||||
|
{
|
||||||
|
label: "Center",
|
||||||
|
value: "center",
|
||||||
|
selected: currentAlignY === "center",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Bottom",
|
||||||
|
value: "bottom",
|
||||||
|
selected: currentAlignY === "bottom",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[currentAlignY],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Typography scale options
|
||||||
|
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.text_size_small"),
|
||||||
|
value: TVTypographyScale.Small,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.Small,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.text_size_default"),
|
||||||
|
value: TVTypographyScale.Default,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.Default,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.text_size_large"),
|
||||||
|
value: TVTypographyScale.Large,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.Large,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.text_size_extra_large"),
|
||||||
|
value: TVTypographyScale.ExtraLarge,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentTypographyScale],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get display labels for option buttons
|
||||||
|
const audioTranscodeLabel = useMemo(() => {
|
||||||
|
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.audio.transcode_mode.auto");
|
||||||
|
}, [audioTranscodeModeOptions, t]);
|
||||||
|
|
||||||
|
const subtitleModeLabel = useMemo(() => {
|
||||||
|
const option = subtitleModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.subtitles.modes.Default");
|
||||||
|
}, [subtitleModeOptions, t]);
|
||||||
|
|
||||||
|
const alignXLabel = useMemo(() => {
|
||||||
|
const option = alignXOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "Center";
|
||||||
|
}, [alignXOptions]);
|
||||||
|
|
||||||
|
const alignYLabel = useMemo(() => {
|
||||||
|
const option = alignYOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "Bottom";
|
||||||
|
}, [alignYOptions]);
|
||||||
|
|
||||||
|
const typographyScaleLabel = useMemo(() => {
|
||||||
|
const option = typographyScaleOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.appearance.text_size_default");
|
||||||
|
}, [typographyScaleOptions, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 120,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.title,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.settings_title")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Audio Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.audio.transcode_mode.title")}
|
||||||
|
value={audioTranscodeLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.audio.transcode_mode.title"),
|
||||||
|
options: audioTranscodeModeOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({ audioTranscodeMode: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isFirst
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtitles Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.subtitles.subtitle_mode")}
|
||||||
|
value={subtitleModeLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.subtitles.subtitle_mode"),
|
||||||
|
options: subtitleModeOptions,
|
||||||
|
onSelect: (value) => updateSettings({ subtitleMode: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
|
value={settings.rememberSubtitleSelections}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label={t("home.settings.subtitles.subtitle_size")}
|
||||||
|
value={settings.subtitleSize / 100}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
|
||||||
|
updateSettings({ subtitleSize: Math.round(newValue * 100) });
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
|
||||||
|
updateSettings({ subtitleSize: Math.round(newValue * 100) });
|
||||||
|
}}
|
||||||
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MPV Subtitles Section */}
|
||||||
|
<TVSectionHeader title='MPV Subtitle Settings' />
|
||||||
|
<TVSettingsStepper
|
||||||
|
label='Subtitle Scale'
|
||||||
|
value={settings.mpvSubtitleScale ?? 1.0}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(
|
||||||
|
0.5,
|
||||||
|
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
|
||||||
|
);
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleScale: Math.round(newValue * 10) / 10,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(
|
||||||
|
2.0,
|
||||||
|
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
|
||||||
|
);
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleScale: Math.round(newValue * 10) / 10,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label='Vertical Margin'
|
||||||
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(
|
||||||
|
0,
|
||||||
|
(settings.mpvSubtitleMarginY ?? 0) - 5,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(
|
||||||
|
100,
|
||||||
|
(settings.mpvSubtitleMarginY ?? 0) + 5,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label='Horizontal Alignment'
|
||||||
|
value={alignXLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: "Horizontal Alignment",
|
||||||
|
options: alignXOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label='Vertical Alignment'
|
||||||
|
value={alignYLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: "Vertical Alignment",
|
||||||
|
options: alignYOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleAlignY: value as "top" | "center" | "bottom",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* OpenSubtitles Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={
|
||||||
|
t("home.settings.subtitles.opensubtitles_title") ||
|
||||||
|
"OpenSubtitles"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#9CA3AF",
|
||||||
|
fontSize: typography.callout - 2,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_hint") ||
|
||||||
|
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
|
||||||
|
</Text>
|
||||||
|
<TVSettingsTextInput
|
||||||
|
label={
|
||||||
|
t("home.settings.subtitles.opensubtitles_api_key") || "API Key"
|
||||||
|
}
|
||||||
|
value={openSubtitlesApiKey}
|
||||||
|
placeholder={
|
||||||
|
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
|
||||||
|
"Enter API key..."
|
||||||
|
}
|
||||||
|
onChangeText={setOpenSubtitlesApiKey}
|
||||||
|
onBlur={() => updateSettings({ openSubtitlesApiKey })}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#6B7280",
|
||||||
|
fontSize: typography.callout - 4,
|
||||||
|
marginTop: 8,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_get_key") ||
|
||||||
|
"Get your free API key at opensubtitles.com/en/consumers"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Appearance Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.appearance.text_size")}
|
||||||
|
value={typographyScaleLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.appearance.text_size"),
|
||||||
|
options: typographyScaleOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({ tvTypographyScale: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t(
|
||||||
|
"home.settings.appearance.merge_next_up_continue_watching",
|
||||||
|
)}
|
||||||
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
|
value={settings.showHomeBackdrop}
|
||||||
|
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_hero_carousel")}
|
||||||
|
value={settings.showTVHeroCarousel}
|
||||||
|
onToggle={(value) => updateSettings({ showTVHeroCarousel: value })}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_series_poster_on_episode")}
|
||||||
|
value={settings.showSeriesPosterOnEpisode}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ showSeriesPosterOnEpisode: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={t("home.settings.user_info.user_info_title")}
|
||||||
|
/>
|
||||||
|
<TVSettingsRow
|
||||||
|
label={t("home.settings.user_info.user")}
|
||||||
|
value={user?.Name || "-"}
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
<TVSettingsRow
|
||||||
|
label={t("home.settings.user_info.server")}
|
||||||
|
value={api?.basePath || "-"}
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<View style={{ marginTop: 48, alignItems: "center" }}>
|
||||||
|
<TVLogoutButton onPress={logout} />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,10 +61,7 @@ export default function Page() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
logsFile.write(JSON.stringify(filteredLogs));
|
logsFile.write(JSON.stringify(filteredLogs));
|
||||||
await Sharing.shareAsync(logsFile.uri, {
|
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
||||||
mimeType: "text/plain",
|
|
||||||
UTI: "public.plain-text",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
writeErrorLog("Something went wrong attempting to export", e);
|
writeErrorLog("Something went wrong attempting to export", e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -15,14 +15,29 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
|
import {
|
||||||
|
TVFilterButton,
|
||||||
|
TVFocusablePoster,
|
||||||
|
TVItemCardText,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +51,10 @@ import {
|
|||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 16;
|
||||||
|
const TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -44,11 +63,15 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
const [orientation, _setOrientation] = useState(
|
const [orientation, _setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
@@ -56,7 +79,7 @@ const page: React.FC = () => {
|
|||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection, isLoading: isCollectionLoading } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -71,6 +94,46 @@ const page: React.FC = () => {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TV Filter queries
|
||||||
|
const { data: tvGenreOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvYearOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Years || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvTagOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({ title: collection?.Name || "" });
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
setSortOrder([SortOrderOption.Ascending]);
|
setSortOrder([SortOrderOption.Ascending]);
|
||||||
@@ -87,6 +150,18 @@ const page: React.FC = () => {
|
|||||||
setSortBy([sortByOption]);
|
setSortBy([sortByOption]);
|
||||||
}, [navigation, collection]);
|
}, [navigation, collection]);
|
||||||
|
|
||||||
|
// Calculate columns for TV grid
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
|
||||||
|
}, [screenWidth, orientation]);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
@@ -98,7 +173,7 @@ const page: React.FC = () => {
|
|||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
limit: 18,
|
limit: Platform.isTV ? 36 : 18,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||||
sortBy: [sortBy[0]],
|
sortBy: [sortBy[0]],
|
||||||
@@ -123,6 +198,7 @@ const page: React.FC = () => {
|
|||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
collection,
|
collection,
|
||||||
|
collectionId,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -131,39 +207,40 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
queryKey: [
|
useInfiniteQuery({
|
||||||
"collection-items",
|
queryKey: [
|
||||||
collection,
|
"collection-items",
|
||||||
selectedGenres,
|
collectionId,
|
||||||
selectedYears,
|
selectedGenres,
|
||||||
selectedTags,
|
selectedYears,
|
||||||
sortBy,
|
selectedTags,
|
||||||
sortOrder,
|
sortBy,
|
||||||
],
|
sortOrder,
|
||||||
queryFn: fetchItems,
|
],
|
||||||
getNextPageParam: (lastPage, pages) => {
|
queryFn: fetchItems,
|
||||||
if (
|
getNextPageParam: (lastPage, pages) => {
|
||||||
!lastPage?.Items ||
|
if (
|
||||||
!lastPage?.TotalRecordCount ||
|
!lastPage?.Items ||
|
||||||
lastPage?.TotalRecordCount === 0
|
!lastPage?.TotalRecordCount ||
|
||||||
)
|
lastPage?.TotalRecordCount === 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
|
const accumulatedItems = pages.reduce(
|
||||||
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accumulatedItems < totalItems) {
|
||||||
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
},
|
||||||
const totalItems = lastPage.TotalRecordCount;
|
initialPageParam: 0,
|
||||||
const accumulatedItems = pages.reduce(
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
});
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
|
||||||
return lastPage?.Items?.length * pages.length;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -195,7 +272,6 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
{/* <MoviePoster item={item} /> */}
|
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
@@ -203,9 +279,38 @@ const page: React.FC = () => {
|
|||||||
[orientation],
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const renderTVItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navTarget = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
const _insets = useSafeAreaInsets();
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginRight: TV_ITEM_GAP,
|
||||||
|
marginBottom: TV_ITEM_GAP,
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusablePoster onPress={handlePress}>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||||
|
<SeriesPoster item={item} />
|
||||||
|
)}
|
||||||
|
{item.Type !== "Movie" &&
|
||||||
|
item.Type !== "Series" &&
|
||||||
|
item.Type !== "Episode" && <MoviePoster item={item} />}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
@@ -372,48 +477,315 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TV Filter options - with "All" option for clearable filters
|
||||||
|
const tvGenreFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedGenres.length === 0,
|
||||||
|
},
|
||||||
|
...(tvGenreOptions || []).map((genre) => ({
|
||||||
|
label: genre,
|
||||||
|
value: genre,
|
||||||
|
selected: selectedGenres.includes(genre),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvGenreOptions, selectedGenres, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvYearFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedYears.length === 0,
|
||||||
|
},
|
||||||
|
...(tvYearOptions || []).map((year) => ({
|
||||||
|
label: String(year),
|
||||||
|
value: String(year),
|
||||||
|
selected: selectedYears.includes(String(year)),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvYearOptions, selectedYears, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvTagFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedTags.length === 0,
|
||||||
|
},
|
||||||
|
...(tvTagOptions || []).map((tag) => ({
|
||||||
|
label: tag,
|
||||||
|
value: tag,
|
||||||
|
selected: selectedTags.includes(tag),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvTagOptions, selectedTags, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortByOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortByOption>[] =>
|
||||||
|
sortOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortBy[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortOrderOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortOrderOption>[] =>
|
||||||
|
sortOrderOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortOrder[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV Filter handlers using navigation-based modal
|
||||||
|
const handleShowGenreFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.genres"),
|
||||||
|
options: tvGenreFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
} else if (selectedGenres.includes(value)) {
|
||||||
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
|
const handleShowYearFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.years"),
|
||||||
|
options: tvYearFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedYears([]);
|
||||||
|
} else if (selectedYears.includes(value)) {
|
||||||
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedYears([...selectedYears, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
|
const handleShowTagFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.tags"),
|
||||||
|
options: tvTagFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedTags([]);
|
||||||
|
} else if (selectedTags.includes(value)) {
|
||||||
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedTags([...selectedTags, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_by"),
|
||||||
|
options: tvSortByOptions,
|
||||||
|
onSelect: (value: SortByOption) => {
|
||||||
|
setSortBy([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||||
|
|
||||||
|
const handleShowSortOrderFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_order"),
|
||||||
|
options: tvSortOrderOptions,
|
||||||
|
onSelect: (value: SortOrderOption) => {
|
||||||
|
setSortOrder([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||||
|
|
||||||
|
// TV filter bar state
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
|
||||||
|
|
||||||
|
if (isLoading || isCollectionLoading) {
|
||||||
|
return (
|
||||||
|
<View className='w-full h-full flex items-center justify-center'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
// Mobile return
|
||||||
<FlashList
|
if (!Platform.isTV) {
|
||||||
ListEmptyComponent={
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<FlashList
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
ListEmptyComponent={
|
||||||
{t("search.no_results")}
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
</View>
|
{t("search.no_results")}
|
||||||
}
|
</Text>
|
||||||
extraData={[
|
</View>
|
||||||
selectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
selectedTags,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
]}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
data={flatData}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
numColumns={
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
}
|
||||||
}}
|
extraData={[
|
||||||
onEndReachedThreshold={0.5}
|
selectedGenres,
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
selectedYears,
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
selectedTags,
|
||||||
ItemSeparatorComponent={() => (
|
sortBy,
|
||||||
<View
|
sortOrder,
|
||||||
style={{
|
]}
|
||||||
width: 10,
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
height: 10,
|
data={flatData}
|
||||||
}}
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV return with filter bar
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Filter bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
marginTop: insets.top + 100,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingHorizontal: TV_SCALE_PADDING,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowGenreFilter}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
/>
|
/>
|
||||||
)}
|
<TVFilterButton
|
||||||
/>
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowYearFilter}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowTagFilter}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={handleShowSortByFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={handleShowSortOrderFilter}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<FlatList
|
||||||
|
key={`${orientation}-${nrOfCols}`}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("search.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: TV_SCALE_PADDING,
|
||||||
|
paddingRight: TV_SCALE_PADDING,
|
||||||
|
paddingTop: 20,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
@@ -15,6 +14,10 @@ import { ItemContent } from "@/components/ItemContent";
|
|||||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
|
const ItemContentSkeletonTV = Platform.isTV
|
||||||
|
? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV
|
||||||
|
: null;
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -24,7 +27,11 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
||||||
// (especially important for plugins like Gelato)
|
// (especially important for plugins like Gelato)
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
|
const {
|
||||||
|
data: item,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
} = useItemQuery(id, isOffline, undefined, [
|
||||||
ItemFields.MediaSources,
|
ItemFields.MediaSources,
|
||||||
ItemFields.MediaSourceCount,
|
ItemFields.MediaSourceCount,
|
||||||
ItemFields.MediaStreams,
|
ItemFields.MediaStreams,
|
||||||
@@ -40,33 +47,14 @@ const Page: React.FC = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const fadeOut = (callback: any) => {
|
// Fast fade out when item loads (no setTimeout delay)
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fadeIn = (callback: any) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
fadeOut(() => {});
|
opacity.value = withTiming(0, { duration: 150 });
|
||||||
} else {
|
} else {
|
||||||
fadeIn(() => {});
|
opacity.value = withTiming(1, { duration: 150 });
|
||||||
}
|
}
|
||||||
}, [item]);
|
}, [item, opacity]);
|
||||||
|
|
||||||
if (isError)
|
if (isError)
|
||||||
return (
|
return (
|
||||||
@@ -78,31 +66,46 @@ const Page: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={isOffline}>
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
<View className='flex flex-1 relative'>
|
<View className='flex flex-1 relative'>
|
||||||
<Animated.View
|
{/* Always render ItemContent - it handles loading state internally on TV */}
|
||||||
pointerEvents={"none"}
|
<ItemContent
|
||||||
style={[animatedStyle]}
|
item={item}
|
||||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
itemWithSources={itemWithSources}
|
||||||
>
|
isLoading={isLoading}
|
||||||
<View
|
/>
|
||||||
style={{
|
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
{/* Skeleton overlay - fades out when content loads */}
|
||||||
}}
|
{!item && (
|
||||||
className='bg-transparent rounded-lg mb-4 w-full'
|
<Animated.View
|
||||||
/>
|
pointerEvents={"none"}
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
style={[animatedStyle]}
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
|
||||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
>
|
||||||
<View className='flex flex-row space-x-1 mb-8'>
|
{Platform.isTV && ItemContentSkeletonTV ? (
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<ItemContentSkeletonTV />
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
) : (
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View style={{ paddingHorizontal: 16, width: "100%" }}>
|
||||||
</View>
|
<View
|
||||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
style={{
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
height: 450,
|
||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
}}
|
||||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
className='bg-transparent rounded-lg mb-4 w-full'
|
||||||
</Animated.View>
|
/>
|
||||||
{item && <ItemContent item={item} itemWithSources={itemWithSources} />}
|
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||||
|
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||||
|
<View className='flex flex-row space-x-1 mb-8'>
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
</View>
|
||||||
|
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</OfflineModeProvider>
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { GenreTags } from "@/components/GenreTags";
|
|||||||
import Cast from "@/components/jellyseerr/Cast";
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
|
import { TVJellyseerrPage } from "@/components/jellyseerr/tv";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
@@ -52,7 +53,8 @@ import type {
|
|||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
// Mobile page component
|
||||||
|
const MobilePage: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -542,4 +544,12 @@ const Page: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Platform-conditional page component
|
||||||
|
const Page: React.FC = () => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVJellyseerrPage />;
|
||||||
|
}
|
||||||
|
return <MobilePage />;
|
||||||
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
|
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -15,6 +15,7 @@ import { Loader } from "@/components/Loader";
|
|||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { TVActorPage } from "@/components/persons/TVActorPage";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
@@ -23,6 +24,16 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
|
// Render TV-optimized page on TV platforms
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVActorPage personId={personId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MobileActorPage personId={personId} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MobileActorPage: React.FC<{ personId: string }> = ({ personId }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
|
import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
@@ -61,6 +62,7 @@ const page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,7 +118,8 @@ const page: React.FC = () => {
|
|||||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
),
|
),
|
||||||
staleTime: isOffline ? Infinity : 60,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,6 +162,19 @@ const page: React.FC = () => {
|
|||||||
// For offline mode, we can show the page even without backdropUrl
|
// For offline mode, we can show the page even without backdropUrl
|
||||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||||
|
|
||||||
|
// TV version
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
|
<TVSeriesPage
|
||||||
|
item={item}
|
||||||
|
allEpisodes={allEpisodes}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</OfflineModeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={isOffline}>
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
|
|||||||
@@ -15,16 +15,37 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import {
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
|
import {
|
||||||
|
TVFilterButton,
|
||||||
|
TVFocusablePoster,
|
||||||
|
TVItemCardText,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +69,11 @@ import {
|
|||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 20;
|
||||||
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
const _TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams() as {
|
const searchParams = useLocalSearchParams() as {
|
||||||
@@ -58,6 +84,7 @@ const Page = () => {
|
|||||||
};
|
};
|
||||||
const { libraryId } = searchParams;
|
const { libraryId } = searchParams;
|
||||||
|
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
@@ -79,6 +106,48 @@ const Page = () => {
|
|||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|
||||||
|
// TV Filter queries
|
||||||
|
const { data: tvGenreOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvYearOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Years", "tvYearFilter", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Years || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvTagOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Tags", "tvTagFilter", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for URL params first (from "See All" navigation)
|
// Check for URL params first (from "See All" navigation)
|
||||||
@@ -162,6 +231,10 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -322,7 +395,37 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation],
|
[orientation, nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navTarget = getItemNavigation(item, "(libraries)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.Id}
|
||||||
|
style={{
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusablePoster onPress={handlePress}>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||||
|
<SeriesPoster item={item} />
|
||||||
|
)}
|
||||||
|
{item.Type !== "Movie" &&
|
||||||
|
item.Type !== "Series" &&
|
||||||
|
item.Type !== "Episode" && <MoviePoster item={item} />}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
@@ -509,6 +612,188 @@ const Page = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TV Filter bar header
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0 ||
|
||||||
|
filterBy.length > 0;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
_setFilterBy([]);
|
||||||
|
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
|
||||||
|
|
||||||
|
// TV Filter options - with "All" option for clearable filters
|
||||||
|
const tvGenreFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedGenres.length === 0,
|
||||||
|
},
|
||||||
|
...(tvGenreOptions || []).map((genre) => ({
|
||||||
|
label: genre,
|
||||||
|
value: genre,
|
||||||
|
selected: selectedGenres.includes(genre),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvGenreOptions, selectedGenres, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvYearFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedYears.length === 0,
|
||||||
|
},
|
||||||
|
...(tvYearOptions || []).map((year) => ({
|
||||||
|
label: String(year),
|
||||||
|
value: String(year),
|
||||||
|
selected: selectedYears.includes(String(year)),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvYearOptions, selectedYears, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvTagFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedTags.length === 0,
|
||||||
|
},
|
||||||
|
...(tvTagOptions || []).map((tag) => ({
|
||||||
|
label: tag,
|
||||||
|
value: tag,
|
||||||
|
selected: selectedTags.includes(tag),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvTagOptions, selectedTags, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortByOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortByOption>[] =>
|
||||||
|
sortOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortBy[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortOrderOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortOrderOption>[] =>
|
||||||
|
sortOrderOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortOrder[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvFilterByOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: filterBy.length === 0,
|
||||||
|
},
|
||||||
|
...generalFilters.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: filterBy.includes(option.key),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[filterBy, generalFilters, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV Filter handlers using navigation-based modal
|
||||||
|
const handleShowGenreFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.genres"),
|
||||||
|
options: tvGenreFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
} else if (selectedGenres.includes(value)) {
|
||||||
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
|
const handleShowYearFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.years"),
|
||||||
|
options: tvYearFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedYears([]);
|
||||||
|
} else if (selectedYears.includes(value)) {
|
||||||
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedYears([...selectedYears, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
|
const handleShowTagFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.tags"),
|
||||||
|
options: tvTagFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedTags([]);
|
||||||
|
} else if (selectedTags.includes(value)) {
|
||||||
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedTags([...selectedTags, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_by"),
|
||||||
|
options: tvSortByOptions,
|
||||||
|
onSelect: (value: SortByOption) => {
|
||||||
|
setSortBy([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||||
|
|
||||||
|
const handleShowSortOrderFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_order"),
|
||||||
|
options: tvSortOrderOptions,
|
||||||
|
onSelect: (value: SortOrderOption) => {
|
||||||
|
setSortOrder([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||||
|
|
||||||
|
const handleShowFilterByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.filter_by"),
|
||||||
|
options: tvFilterByOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
_setFilterBy([]);
|
||||||
|
} else {
|
||||||
|
setFilter([value as FilterByOption]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading || isLibraryLoading)
|
if (isLoading || isLibraryLoading)
|
||||||
@@ -518,43 +803,176 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mobile return
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
key={orientation}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV return with filter bar
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<ScrollView
|
||||||
key={orientation}
|
style={{ flex: 1 }}
|
||||||
ListEmptyComponent={
|
contentContainerStyle={{
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
paddingTop: insets.top + 100,
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
paddingBottom: insets.bottom + 60,
|
||||||
{t("library.no_results")}
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
</Text>
|
}}
|
||||||
</View>
|
onScroll={({ nativeEvent }) => {
|
||||||
}
|
// Load more when near bottom
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||||
data={flatData}
|
const isNearBottom =
|
||||||
renderItem={renderItem}
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
extraData={[orientation, nrOfCols]}
|
contentSize.height - 500;
|
||||||
keyExtractor={keyExtractor}
|
if (isNearBottom && hasNextPage && !isFetching) {
|
||||||
numColumns={nrOfCols}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onEndReachedThreshold={1}
|
scrollEventThrottle={400}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
>
|
||||||
contentContainerStyle={{
|
{/* Filter bar */}
|
||||||
paddingBottom: 24,
|
<View
|
||||||
paddingLeft: insets.left,
|
style={{
|
||||||
paddingRight: insets.right,
|
flexDirection: "row",
|
||||||
}}
|
flexWrap: "nowrap",
|
||||||
ItemSeparatorComponent={() => (
|
justifyContent: "center",
|
||||||
|
paddingBottom: 24,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowGenreFilter}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowYearFilter}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowTagFilter}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={handleShowSortByFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={handleShowSortOrderFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.filter_by")}
|
||||||
|
value={
|
||||||
|
filterBy.length > 0
|
||||||
|
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowFilterByFilter}
|
||||||
|
hasActiveFilter={filterBy.length > 0}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid with flexWrap */}
|
||||||
|
{flatData.length === 0 ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
flex: 1,
|
||||||
height: 10,
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 100,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||||
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flatData.map((item) => renderTVItem(item))}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isFetching && (
|
||||||
|
<View style={{ paddingVertical: 20 }}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,109 +1,11 @@
|
|||||||
import {
|
import { Platform } from "react-native";
|
||||||
getUserLibraryApi,
|
import { Libraries } from "@/components/library/Libraries";
|
||||||
getUserViewsApi,
|
import { TVLibraries } from "@/components/library/TVLibraries";
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, StyleSheet, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function index() {
|
export default function LibrariesPage() {
|
||||||
const [api] = useAtom(apiAtom);
|
if (Platform.isTV) {
|
||||||
const [user] = useAtom(userAtom);
|
return <TVLibraries />;
|
||||||
const queryClient = useQueryClient();
|
}
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
return <Libraries />;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ["user-views", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getUserViewsApi(api!).getUserViews({
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
staleTime: 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
const libraries = useMemo(
|
|
||||||
() =>
|
|
||||||
data
|
|
||||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
|
||||||
.filter((l) => l.CollectionType !== "books") || [],
|
|
||||||
[data, settings?.hiddenLibraries],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (const item of data || []) {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["library", item.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!item.Id || !user?.Id || !api) return null;
|
|
||||||
const response = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId: item.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<View className='justify-center items-center h-full'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!libraries)
|
|
||||||
return (
|
|
||||||
<View className='h-full w-full flex justify-center items-center'>
|
|
||||||
<Text className='text-lg text-neutral-500'>
|
|
||||||
{t("library.no_libraries_found")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlashList
|
|
||||||
extraData={settings}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
|
||||||
paddingBottom: 150,
|
|
||||||
paddingLeft: insets.left + 17,
|
|
||||||
paddingRight: insets.right + 17,
|
|
||||||
}}
|
|
||||||
data={libraries}
|
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
|
||||||
keyExtractor={(item) => item.Id || ""}
|
|
||||||
ItemSeparatorComponent={() =>
|
|
||||||
settings?.libraryOptions?.display === "row" ? (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='bg-neutral-800 mx-2 my-4'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className='h-4' />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
import { orderBy, uniqBy } from "lodash";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -22,9 +23,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
@@ -36,12 +39,19 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
|||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
|
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
PersonResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { createStreamystatsApi } from "@/utils/streamystats";
|
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
@@ -59,6 +69,8 @@ export default function search() {
|
|||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(search)";
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -199,9 +211,7 @@ export default function search() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${
|
const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
settings.marlinServerUrl
|
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
|
||||||
.map((type) => encodeURIComponent(type))
|
.map((type) => encodeURIComponent(type))
|
||||||
.join("&includeItemTypes=")}`;
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
@@ -440,6 +450,179 @@ export default function search() {
|
|||||||
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
|
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
|
||||||
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
|
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
|
||||||
|
|
||||||
|
// TV item press handler
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jellyseerr search for TV
|
||||||
|
const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } =
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["search", "jellyseerr", "tv", debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = {
|
||||||
|
query: new URLSearchParams(debouncedSearch || "").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 }),
|
||||||
|
]).then((all) =>
|
||||||
|
uniqBy(
|
||||||
|
all.flatMap((v) => v?.results || []),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
Platform.isTV &&
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process Jellyseerr results for TV
|
||||||
|
const jellyseerrMovieResults = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
jellyseerrTVResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.MOVIE,
|
||||||
|
) as MovieResult[],
|
||||||
|
[(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
[jellyseerrTVResults, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrTvResults = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
jellyseerrTVResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.TV,
|
||||||
|
) as TvResult[],
|
||||||
|
[(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
[jellyseerrTVResults, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrPersonResults = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
jellyseerrTVResults?.filter(
|
||||||
|
(r) => r.mediaType === "person",
|
||||||
|
) as PersonResult[],
|
||||||
|
[(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
[jellyseerrTVResults, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrTVNoResults = useMemo(() => {
|
||||||
|
return (
|
||||||
|
!jellyseerrMovieResults?.length &&
|
||||||
|
!jellyseerrTvResults?.length &&
|
||||||
|
!jellyseerrPersonResults?.length
|
||||||
|
);
|
||||||
|
}, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]);
|
||||||
|
|
||||||
|
// Fetch discover settings for TV (when no search query in Discover mode)
|
||||||
|
const { data: discoverSliders } = useQuery({
|
||||||
|
queryKey: ["search", "jellyseerr", "discoverSettings", "tv"],
|
||||||
|
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||||
|
enabled:
|
||||||
|
Platform.isTV &&
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TV Jellyseerr press handlers
|
||||||
|
const handleJellyseerrMoviePress = useCallback(
|
||||||
|
(item: MovieResult) => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
|
params: {
|
||||||
|
mediaTitle: item.title,
|
||||||
|
releaseYear: String(new Date(item.releaseDate || "").getFullYear()),
|
||||||
|
canRequest: "true",
|
||||||
|
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
|
||||||
|
mediaType: MediaType.MOVIE,
|
||||||
|
id: String(item.id),
|
||||||
|
backdropPath: item.backdropPath || "",
|
||||||
|
overview: item.overview || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router, jellyseerrApi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJellyseerrTvPress = useCallback(
|
||||||
|
(item: TvResult) => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
|
params: {
|
||||||
|
mediaTitle: item.name,
|
||||||
|
releaseYear: String(new Date(item.firstAirDate || "").getFullYear()),
|
||||||
|
canRequest: "true",
|
||||||
|
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
id: String(item.id),
|
||||||
|
backdropPath: item.backdropPath || "",
|
||||||
|
overview: item.overview || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router, jellyseerrApi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJellyseerrPersonPress = useCallback(
|
||||||
|
(item: PersonResult) => {
|
||||||
|
router.push(`/(auth)/jellyseerr/person/${item.id}` as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render TV search page
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<TVSearchPage
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
|
debouncedSearch={debouncedSearch}
|
||||||
|
movies={movies}
|
||||||
|
series={series}
|
||||||
|
episodes={episodes}
|
||||||
|
collections={collections}
|
||||||
|
actors={actors}
|
||||||
|
artists={artists}
|
||||||
|
albums={albums}
|
||||||
|
songs={songs}
|
||||||
|
playlists={playlists}
|
||||||
|
loading={loading}
|
||||||
|
noResults={noResults}
|
||||||
|
onItemPress={handleItemPress}
|
||||||
|
searchType={searchType}
|
||||||
|
setSearchType={setSearchType}
|
||||||
|
showDiscover={!!jellyseerrApi}
|
||||||
|
jellyseerrMovies={jellyseerrMovieResults}
|
||||||
|
jellyseerrTv={jellyseerrTvResults}
|
||||||
|
jellyseerrPersons={jellyseerrPersonResults}
|
||||||
|
jellyseerrLoading={jellyseerrTVLoading}
|
||||||
|
jellyseerrNoResults={jellyseerrTVNoResults}
|
||||||
|
onJellyseerrMoviePress={handleJellyseerrMoviePress}
|
||||||
|
onJellyseerrTvPress={handleJellyseerrTvPress}
|
||||||
|
onJellyseerrPersonPress={handleJellyseerrPersonPress}
|
||||||
|
discoverSliders={discoverSliders}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
keyboardDismissMode='on-drag'
|
keyboardDismissMode='on-drag'
|
||||||
@@ -450,26 +633,6 @@ export default function search() {
|
|||||||
paddingBottom: 60,
|
paddingBottom: 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* <View
|
|
||||||
className='flex flex-col'
|
|
||||||
style={{
|
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
|
||||||
}}
|
|
||||||
> */}
|
|
||||||
{Platform.isTV && (
|
|
||||||
<Input
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
router.setParams({ q: "" });
|
|
||||||
setSearch(text);
|
|
||||||
}}
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
|||||||
21
app/(auth)/(tabs)/(settings)/_layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name='index'
|
||||||
|
options={{
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerTitle: t("tabs.settings"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(auth)/(tabs)/(settings)/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv";
|
||||||
|
|
||||||
|
export default function SettingsTabScreen() {
|
||||||
|
return <SettingsTV />;
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
@@ -16,9 +18,18 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +43,36 @@ import {
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 20;
|
||||||
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
|
||||||
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => (
|
||||||
|
<View style={{ marginTop: 12 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout - 2,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
export default function WatchlistDetailScreen() {
|
export default function WatchlistDetailScreen() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -47,6 +87,8 @@ export default function WatchlistDetailScreen() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
|
if (Platform.isTV) return 1;
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -153,6 +195,36 @@ export default function WatchlistDetailScreen() {
|
|||||||
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
(item: BaseItemDto, index: number) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navigation = getItemNavigation(item, "(watchlists)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.Id}
|
||||||
|
style={{
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={handlePress}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||||
|
<SeriesPoster item={item} />
|
||||||
|
)}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} typography={typography} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router, typography],
|
||||||
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -266,6 +338,120 @@ export default function WatchlistDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TV layout with ScrollView + flexWrap
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 100,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingBottom: 24,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={20} color='#9ca3af' />
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{items?.length ?? 0}{" "}
|
||||||
|
{(items?.length ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{watchlist.isPublic
|
||||||
|
? t("watchlists.public")
|
||||||
|
: t("watchlists.private")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isOwner && (
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
|
||||||
|
{t("watchlists.by_owner")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid with flexWrap */}
|
||||||
|
{!items || items.length === 0 ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("watchlists.empty_watchlist")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => renderTVItem(item, index))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile layout with FlashList
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function WatchlistsLayout() {
|
|||||||
name='[watchlistId]'
|
name='[watchlistId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -51,7 +51,7 @@ export default function WatchlistsLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: t("watchlists.create_title"),
|
title: t("watchlists.create_title"),
|
||||||
presentation: "modal",
|
presentation: "modal",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerStyle: { backgroundColor: "#171717" },
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
headerTintColor: "white",
|
headerTintColor: "white",
|
||||||
contentStyle: { backgroundColor: "#171717" },
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
@@ -62,7 +62,7 @@ export default function WatchlistsLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: t("watchlists.edit_title"),
|
title: t("watchlists.edit_title"),
|
||||||
presentation: "modal",
|
presentation: "modal",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerStyle: { backgroundColor: "#171717" },
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
headerTintColor: "white",
|
headerTintColor: "white",
|
||||||
contentStyle: { backgroundColor: "#171717" },
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
|
|||||||
@@ -11,12 +11,18 @@ import { withLayoutContext } from "expo-router";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
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 { Colors } from "@/constants/Colors";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
|
// Music components are not available on tvOS (TrackPlayer not supported)
|
||||||
|
const MiniPlayerBar = Platform.isTV
|
||||||
|
? () => null
|
||||||
|
: require("@/components/music/MiniPlayerBar").MiniPlayerBar;
|
||||||
|
const MusicPlaybackEngine = Platform.isTV
|
||||||
|
? () => null
|
||||||
|
: require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine;
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
@@ -117,6 +123,17 @@ export default function TabLayout() {
|
|||||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name='(settings)'
|
||||||
|
options={{
|
||||||
|
title: t("tabs.settings"),
|
||||||
|
tabBarItemHidden: !Platform.isTV,
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS === "android"
|
||||||
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
|
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type BaseItemDto,
|
type BaseItemDto,
|
||||||
type MediaSourceInfo,
|
type MediaSourceInfo,
|
||||||
|
type MediaStream,
|
||||||
PlaybackOrder,
|
PlaybackOrder,
|
||||||
PlaybackProgressInfo,
|
PlaybackProgressInfo,
|
||||||
RepeatMode,
|
RepeatMode,
|
||||||
@@ -20,6 +21,7 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
|
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
||||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +50,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
@@ -85,6 +87,12 @@ export default function page() {
|
|||||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
||||||
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
|
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
|
||||||
|
|
||||||
|
// TV audio/subtitle selection state (tracks current selection for dynamic changes)
|
||||||
|
const [currentAudioIndex, setCurrentAudioIndex] = useState<
|
||||||
|
number | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [currentSubtitleIndex, setCurrentSubtitleIndex] = useState<number>(-1);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
@@ -127,7 +135,6 @@ export default function page() {
|
|||||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager({ isOffline: offline });
|
|
||||||
|
|
||||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
// This is computed after downloadedItem is available, see audioIndexResolved below
|
// This is computed after downloadedItem is available, see audioIndexResolved below
|
||||||
@@ -150,6 +157,10 @@ export default function page() {
|
|||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Playback manager for progress reporting and adjacent items
|
||||||
|
const playbackManager = usePlaybackManager({ item, isOffline: offline });
|
||||||
|
const { nextItem, previousItem } = playbackManager;
|
||||||
|
|
||||||
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
|
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
const audioIndex = useMemo(() => {
|
const audioIndex = useMemo(() => {
|
||||||
if (audioIndexFromUrl !== undefined) {
|
if (audioIndexFromUrl !== undefined) {
|
||||||
@@ -161,6 +172,17 @@ export default function page() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
||||||
|
|
||||||
|
// Initialize TV audio/subtitle indices from URL params
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioIndex !== undefined) {
|
||||||
|
setCurrentAudioIndex(audioIndex);
|
||||||
|
}
|
||||||
|
}, [audioIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSubtitleIndex(subtitleIndex);
|
||||||
|
}, [subtitleIndex]);
|
||||||
|
|
||||||
// Get the playback speed for this item based on settings
|
// Get the playback speed for this item based on settings
|
||||||
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
||||||
item,
|
item,
|
||||||
@@ -248,15 +270,18 @@ export default function page() {
|
|||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ref to store the stream fetch function for refreshing subtitle tracks
|
||||||
|
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async () => {
|
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
try {
|
try {
|
||||||
// Don't attempt to fetch stream data if item is not available
|
// Don't attempt to fetch stream data if item is not available
|
||||||
if (!item?.Id) {
|
if (!item?.Id) {
|
||||||
console.log("Item not loaded yet, skipping stream data fetch");
|
console.log("Item not loaded yet, skipping stream data fetch");
|
||||||
setStreamStatus({ isLoading: false, isError: false });
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
@@ -274,12 +299,12 @@ export default function page() {
|
|||||||
if (!api) {
|
if (!api) {
|
||||||
console.warn("API not available for streaming");
|
console.warn("API not available for streaming");
|
||||||
setStreamStatus({ isLoading: false, isError: true });
|
setStreamStatus({ isLoading: false, isError: true });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if (!user?.Id) {
|
if (!user?.Id) {
|
||||||
console.warn("User not authenticated for streaming");
|
console.warn("User not authenticated for streaming");
|
||||||
setStreamStatus({ isLoading: false, isError: true });
|
setStreamStatus({ isLoading: false, isError: true });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate start ticks directly from item to avoid stale closure
|
// Calculate start ticks directly from item to avoid stale closure
|
||||||
@@ -298,7 +323,7 @@ export default function page() {
|
|||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: generateDeviceProfile(),
|
deviceProfile: generateDeviceProfile(),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return null;
|
||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
if (!sessionId || !mediaSource || !url) {
|
||||||
@@ -306,17 +331,22 @@ export default function page() {
|
|||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.failed_to_get_stream_url"),
|
t("player.failed_to_get_stream_url"),
|
||||||
);
|
);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
result = { mediaSource, sessionId, url };
|
result = { mediaSource, sessionId, url };
|
||||||
}
|
}
|
||||||
setStream(result);
|
setStream(result);
|
||||||
setStreamStatus({ isLoading: false, isError: false });
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch stream:", error);
|
console.error("Failed to fetch stream:", error);
|
||||||
setStreamStatus({ isLoading: false, isError: true });
|
setStreamStatus({ isLoading: false, isError: true });
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store the fetch function in ref for use by refresh handler
|
||||||
|
refetchStreamRef.current = fetchStreamData;
|
||||||
fetchStreamData();
|
fetchStreamData();
|
||||||
}, [
|
}, [
|
||||||
itemId,
|
itemId,
|
||||||
@@ -505,31 +535,6 @@ export default function page() {
|
|||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
}, [getInitialPlaybackTicks]);
|
}, [getInitialPlaybackTicks]);
|
||||||
|
|
||||||
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
|
|
||||||
const nowPlayingMetadata = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
|
|
||||||
const artworkUri = getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 500,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: item.Name || "",
|
|
||||||
artist:
|
|
||||||
item.Type === "Episode"
|
|
||||||
? item.SeriesName || ""
|
|
||||||
: item.AlbumArtist || "",
|
|
||||||
albumTitle:
|
|
||||||
item.Type === "Episode" && item.SeasonName
|
|
||||||
? item.SeasonName
|
|
||||||
: undefined,
|
|
||||||
artworkUri: artworkUri || undefined,
|
|
||||||
};
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
/** Build video source config for MPV */
|
/** Build video source config for MPV */
|
||||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||||
if (!stream?.url) return undefined;
|
if (!stream?.url) return undefined;
|
||||||
@@ -763,6 +768,55 @@ export default function page() {
|
|||||||
videoRef.current?.seekTo?.(position / 1000);
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// TV audio track change handler
|
||||||
|
const handleAudioIndexChange = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
|
setCurrentAudioIndex(index);
|
||||||
|
|
||||||
|
// Check if we're transcoding
|
||||||
|
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
|
// Convert Jellyfin index to MPV track ID
|
||||||
|
const mpvTrackId = getMpvAudioId(
|
||||||
|
stream?.mediaSource,
|
||||||
|
index,
|
||||||
|
isTranscoding,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mpvTrackId !== undefined) {
|
||||||
|
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stream?.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV subtitle track change handler
|
||||||
|
const handleSubtitleIndexChange = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
|
setCurrentSubtitleIndex(index);
|
||||||
|
|
||||||
|
// Check if we're transcoding
|
||||||
|
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
// Disable subtitles
|
||||||
|
await videoRef.current?.disableSubtitles?.();
|
||||||
|
} else {
|
||||||
|
// Convert Jellyfin index to MPV track ID
|
||||||
|
const mpvTrackId = getMpvSubtitleId(
|
||||||
|
stream?.mediaSource,
|
||||||
|
index,
|
||||||
|
isTranscoding,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
|
||||||
|
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stream?.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
// Technical info toggle handler
|
// Technical info toggle handler
|
||||||
const handleToggleTechnicalInfo = useCallback(() => {
|
const handleToggleTechnicalInfo = useCallback(() => {
|
||||||
setShowTechnicalInfo((prev) => !prev);
|
setShowTechnicalInfo((prev) => !prev);
|
||||||
@@ -852,6 +906,109 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
|
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
|
||||||
|
|
||||||
|
// TV: Navigate to previous item
|
||||||
|
const goToPreviousItem = useCallback(() => {
|
||||||
|
if (!previousItem || !settings) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
mediaSource: newMediaSource,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
|
} = getDefaultPlaySettings(previousItem, settings, {
|
||||||
|
indexes: {
|
||||||
|
subtitleIndex: subtitleIndex,
|
||||||
|
audioIndex: audioIndex,
|
||||||
|
},
|
||||||
|
source: stream?.mediaSource ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: previousItem.Id ?? "",
|
||||||
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: newMediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrateValue?.toString() ?? "",
|
||||||
|
playbackPosition:
|
||||||
|
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
|
}, [
|
||||||
|
previousItem,
|
||||||
|
settings,
|
||||||
|
subtitleIndex,
|
||||||
|
audioIndex,
|
||||||
|
stream?.mediaSource,
|
||||||
|
bitrateValue,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||||
|
const addSubtitleFile = useCallback(async (path: string) => {
|
||||||
|
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// TV: Refresh subtitle tracks after server-side subtitle download
|
||||||
|
// Re-fetches the media source to pick up newly downloaded subtitles
|
||||||
|
const handleRefreshSubtitleTracks = useCallback(async (): Promise<
|
||||||
|
MediaStream[]
|
||||||
|
> => {
|
||||||
|
if (!refetchStreamRef.current) return [];
|
||||||
|
|
||||||
|
const newStream = await refetchStreamRef.current();
|
||||||
|
|
||||||
|
// Check if component is still mounted before updating state
|
||||||
|
// This callback may be invoked from a modal after the player unmounts
|
||||||
|
if (!isMounted) return [];
|
||||||
|
|
||||||
|
if (newStream) {
|
||||||
|
setStream(newStream);
|
||||||
|
return (
|
||||||
|
newStream.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [isMounted]);
|
||||||
|
|
||||||
|
// TV: Navigate to next item
|
||||||
|
const goToNextItem = useCallback(() => {
|
||||||
|
if (!nextItem || !settings) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
mediaSource: newMediaSource,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
|
} = getDefaultPlaySettings(nextItem, settings, {
|
||||||
|
indexes: {
|
||||||
|
subtitleIndex: subtitleIndex,
|
||||||
|
audioIndex: audioIndex,
|
||||||
|
},
|
||||||
|
source: stream?.mediaSource ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: nextItem.Id ?? "",
|
||||||
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: newMediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrateValue?.toString() ?? "",
|
||||||
|
playbackPosition:
|
||||||
|
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
|
}, [
|
||||||
|
nextItem,
|
||||||
|
settings,
|
||||||
|
subtitleIndex,
|
||||||
|
audioIndex,
|
||||||
|
stream?.mediaSource,
|
||||||
|
bitrateValue,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideoLoaded || !videoRef.current) return;
|
if (!isVideoLoaded || !videoRef.current) return;
|
||||||
@@ -958,7 +1115,6 @@ export default function page() {
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
nowPlayingMetadata={nowPlayingMetadata}
|
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
@@ -991,37 +1147,71 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{isMounted === true && item && !isPipMode && (
|
{isMounted === true &&
|
||||||
<Controls
|
item &&
|
||||||
mediaSource={stream?.mediaSource}
|
!isPipMode &&
|
||||||
item={item}
|
(Platform.isTV ? (
|
||||||
togglePlay={togglePlay}
|
<TVControls
|
||||||
isPlaying={isPlaying}
|
mediaSource={stream?.mediaSource}
|
||||||
isSeeking={isSeeking}
|
item={item}
|
||||||
progress={progress}
|
togglePlay={togglePlay}
|
||||||
cacheProgress={cacheProgress}
|
isPlaying={isPlaying}
|
||||||
isBuffering={isBuffering}
|
isSeeking={isSeeking}
|
||||||
showControls={showControls}
|
progress={progress}
|
||||||
setShowControls={setShowControls}
|
cacheProgress={cacheProgress}
|
||||||
startPictureInPicture={startPictureInPicture}
|
isBuffering={isBuffering}
|
||||||
play={play}
|
showControls={showControls}
|
||||||
pause={pause}
|
setShowControls={setShowControls}
|
||||||
seek={seek}
|
play={play}
|
||||||
enableTrickplay={true}
|
pause={pause}
|
||||||
aspectRatio={aspectRatio}
|
seek={seek}
|
||||||
isZoomedToFill={isZoomedToFill}
|
audioIndex={currentAudioIndex}
|
||||||
onZoomToggle={handleZoomToggle}
|
subtitleIndex={currentSubtitleIndex}
|
||||||
api={api}
|
onAudioIndexChange={handleAudioIndexChange}
|
||||||
downloadedFiles={downloadedFiles}
|
onSubtitleIndexChange={handleSubtitleIndexChange}
|
||||||
playbackSpeed={currentPlaybackSpeed}
|
previousItem={previousItem}
|
||||||
setPlaybackSpeed={handleSetPlaybackSpeed}
|
nextItem={nextItem}
|
||||||
showTechnicalInfo={showTechnicalInfo}
|
goToPreviousItem={goToPreviousItem}
|
||||||
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
goToNextItem={goToNextItem}
|
||||||
getTechnicalInfo={getTechnicalInfo}
|
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
||||||
playMethod={playMethod}
|
addSubtitleFile={addSubtitleFile}
|
||||||
transcodeReasons={transcodeReasons}
|
showTechnicalInfo={showTechnicalInfo}
|
||||||
/>
|
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
||||||
)}
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
|
playMethod={playMethod}
|
||||||
|
transcodeReasons={transcodeReasons}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Controls
|
||||||
|
mediaSource={stream?.mediaSource}
|
||||||
|
item={item}
|
||||||
|
togglePlay={togglePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
progress={progress}
|
||||||
|
cacheProgress={cacheProgress}
|
||||||
|
isBuffering={isBuffering}
|
||||||
|
showControls={showControls}
|
||||||
|
setShowControls={setShowControls}
|
||||||
|
startPictureInPicture={startPictureInPicture}
|
||||||
|
play={play}
|
||||||
|
pause={pause}
|
||||||
|
seek={seek}
|
||||||
|
enableTrickplay={true}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
isZoomedToFill={isZoomedToFill}
|
||||||
|
onZoomToggle={handleZoomToggle}
|
||||||
|
api={api}
|
||||||
|
downloadedFiles={downloadedFiles}
|
||||||
|
playbackSpeed={currentPlaybackSpeed}
|
||||||
|
setPlaybackSpeed={handleSetPlaybackSpeed}
|
||||||
|
showTechnicalInfo={showTechnicalInfo}
|
||||||
|
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
||||||
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
|
playMethod={playMethod}
|
||||||
|
transcodeReasons={transcodeReasons}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</VideoProvider>
|
</VideoProvider>
|
||||||
</PlayerProvider>
|
</PlayerProvider>
|
||||||
|
|||||||
171
app/(auth)/tv-option-modal.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVOptionCard } from "@/components/tv";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
export default function TVOptionModal() {
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvOptionModalAtom);
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const firstCardRef = useRef<View>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
const initialSelectedIndex = useMemo(() => {
|
||||||
|
if (!modalState?.options) return 0;
|
||||||
|
const idx = modalState.options.findIndex((o) => o.selected);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}, [modalState?.options]);
|
||||||
|
|
||||||
|
// Animate in on mount and cleanup atom on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
// Delay focus setup to allow layout
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
// Clear the atom on unmount to prevent stale callbacks from being retained
|
||||||
|
store.set(tvOptionModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Request focus on the first card when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
const handleSelect = (value: any) => {
|
||||||
|
modalState?.onSelect(value);
|
||||||
|
store.set(tvOptionModalAtom, null);
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no modal state, just go back (shouldn't happen in normal usage)
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, options, cardWidth = 160, cardHeight = 75 } = modalState;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={index}
|
||||||
|
ref={
|
||||||
|
index === initialSelectedIndex ? firstCardRef : undefined
|
||||||
|
}
|
||||||
|
label={option.label}
|
||||||
|
sublabel={option.sublabel}
|
||||||
|
selected={option.selected}
|
||||||
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||||
|
onPress={() => handleSelect(option.value)}
|
||||||
|
width={cardWidth}
|
||||||
|
height={cardHeight}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 20,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
496
app/(auth)/tv-request-modal.tsx
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
|
||||||
|
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
||||||
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||||
|
import type {
|
||||||
|
QualityProfile,
|
||||||
|
RootFolder,
|
||||||
|
Tag,
|
||||||
|
} from "@/utils/jellyseerr/server/api/servarr/base";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
export default function TVRequestModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvRequestModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||||
|
mediaId: modalState?.id ? Number(modalState.id) : 0,
|
||||||
|
mediaType: modalState?.mediaType,
|
||||||
|
userId: jellyseerrUser?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeSelector, setActiveSelector] = useState<
|
||||||
|
"profile" | "folder" | "user" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
store.set(tvRequestModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
const { data: serviceSettings } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.service(
|
||||||
|
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
|
||||||
|
),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: users } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "users"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultService = useMemo(
|
||||||
|
() => serviceSettings?.find?.((v) => v.isDefault),
|
||||||
|
[serviceSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: defaultServiceDetails } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"jellyseerr",
|
||||||
|
"request",
|
||||||
|
modalState?.mediaType,
|
||||||
|
"service",
|
||||||
|
"details",
|
||||||
|
defaultService?.id,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serverId: defaultService?.id,
|
||||||
|
}));
|
||||||
|
return jellyseerrApi?.serviceDetails(
|
||||||
|
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
|
||||||
|
defaultService!.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
!!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProfile: QualityProfile | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.profiles.find(
|
||||||
|
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
|
||||||
|
),
|
||||||
|
[defaultServiceDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultFolder: RootFolder | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.rootFolders.find(
|
||||||
|
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
|
||||||
|
),
|
||||||
|
[defaultServiceDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultTags: Tag[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
defaultServiceDetails?.tags.filter((t) =>
|
||||||
|
defaultServiceDetails?.server.activeTags?.includes(t.id),
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}, [defaultServiceDetails]);
|
||||||
|
|
||||||
|
const pathTitleExtractor = (item: RootFolder) =>
|
||||||
|
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||||
|
|
||||||
|
// Option builders
|
||||||
|
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.profiles.map((profile) => ({
|
||||||
|
label: profile.name,
|
||||||
|
value: profile.id,
|
||||||
|
selected:
|
||||||
|
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
|
||||||
|
})) || [],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
defaultProfile,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||||
|
label: pathTitleExtractor(folder),
|
||||||
|
value: folder.path,
|
||||||
|
selected:
|
||||||
|
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
|
||||||
|
})) || [],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
defaultFolder,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const userOptions: TVOptionItem<number>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
users?.map((user) => ({
|
||||||
|
label: user.displayName,
|
||||||
|
value: user.id,
|
||||||
|
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||||
|
})) || [],
|
||||||
|
[users, jellyseerrUser, requestOverrides.userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagItems = useMemo(() => {
|
||||||
|
return (
|
||||||
|
defaultServiceDetails?.tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
label: tag.label,
|
||||||
|
selected:
|
||||||
|
requestOverrides.tags?.includes(tag.id) ||
|
||||||
|
defaultTags.some((dt) => dt.id === tag.id),
|
||||||
|
})) ?? []
|
||||||
|
);
|
||||||
|
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
|
||||||
|
|
||||||
|
// Selected display values
|
||||||
|
const selectedProfileName = useMemo(() => {
|
||||||
|
const profile = defaultServiceDetails?.profiles.find(
|
||||||
|
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
|
||||||
|
);
|
||||||
|
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
|
||||||
|
}, [
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
defaultProfile,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedFolderName = useMemo(() => {
|
||||||
|
const folder = defaultServiceDetails?.rootFolders.find(
|
||||||
|
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
|
||||||
|
);
|
||||||
|
return folder
|
||||||
|
? pathTitleExtractor(folder)
|
||||||
|
: defaultFolder
|
||||||
|
? pathTitleExtractor(defaultFolder)
|
||||||
|
: t("jellyseerr.select");
|
||||||
|
}, [
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
defaultFolder,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedUserName = useMemo(() => {
|
||||||
|
const user = users?.find(
|
||||||
|
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
|
||||||
|
);
|
||||||
|
}, [users, requestOverrides.userId, jellyseerrUser, t]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleProfileChange = useCallback((profileId: number) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, profileId }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFolderChange = useCallback((rootFolder: string) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUserChange = useCallback((userId: number) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, userId }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTagToggle = useCallback(
|
||||||
|
(tagId: number) => {
|
||||||
|
setRequestOverrides((prev) => {
|
||||||
|
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||||
|
const hasTag = currentTags.includes(tagId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tags: hasTag
|
||||||
|
? currentTags.filter((id) => id !== tagId)
|
||||||
|
: [...currentTags, tagId],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defaultTags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRequest = useCallback(() => {
|
||||||
|
if (!modalState) return;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||||
|
profileId: defaultProfile?.id,
|
||||||
|
rootFolder: defaultFolder?.path,
|
||||||
|
tags: defaultTags.map((t) => t.id),
|
||||||
|
...modalState.requestBody,
|
||||||
|
...requestOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const seasonTitle =
|
||||||
|
modalState.requestBody?.seasons?.length === 1
|
||||||
|
? t("jellyseerr.season_number", {
|
||||||
|
season_number: modalState.requestBody.seasons[0],
|
||||||
|
})
|
||||||
|
: modalState.requestBody?.seasons &&
|
||||||
|
modalState.requestBody.seasons.length > 1
|
||||||
|
? t("jellyseerr.season_all")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
requestMedia(
|
||||||
|
seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title,
|
||||||
|
body,
|
||||||
|
() => {
|
||||||
|
modalState.onRequested();
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
modalState,
|
||||||
|
requestOverrides,
|
||||||
|
defaultProfile,
|
||||||
|
defaultFolder,
|
||||||
|
defaultTags,
|
||||||
|
defaultService,
|
||||||
|
defaultServiceDetails,
|
||||||
|
requestMedia,
|
||||||
|
router,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDataLoaded = defaultService && defaultServiceDetails && users;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
|
{t("jellyseerr.advanced")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isDataLoaded && isReady ? (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.optionsContainer}>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.quality_profile")}
|
||||||
|
value={selectedProfileName}
|
||||||
|
onPress={() => setActiveSelector("profile")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.root_folder")}
|
||||||
|
value={selectedFolderName}
|
||||||
|
onPress={() => setActiveSelector("folder")}
|
||||||
|
/>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.request_as")}
|
||||||
|
value={selectedUserName}
|
||||||
|
onPress={() => setActiveSelector("user")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tagItems.length > 0 && (
|
||||||
|
<TVToggleOptionRow
|
||||||
|
label={t("jellyseerr.tags")}
|
||||||
|
items={tagItems}
|
||||||
|
onToggle={handleTagToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>{t("common.loading")}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequest}
|
||||||
|
variant='secondary'
|
||||||
|
disabled={!isDataLoaded}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.buttonText,
|
||||||
|
{ fontSize: typography.callout },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_button")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Sub-selectors */}
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "profile"}
|
||||||
|
title={t("jellyseerr.quality_profile")}
|
||||||
|
options={qualityProfileOptions}
|
||||||
|
onSelect={handleProfileChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "folder"}
|
||||||
|
title={t("jellyseerr.root_folder")}
|
||||||
|
options={rootFolderOptions}
|
||||||
|
onSelect={handleFolderChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
cardWidth={280}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "user"}
|
||||||
|
title={t("jellyseerr.request_as")}
|
||||||
|
options={userOptions}
|
||||||
|
onSelect={handleUserChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 44,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
maxHeight: 320,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
optionsContainer: {
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
height: 200,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
},
|
||||||
|
});
|
||||||
446
app/(auth)/tv-season-select-modal.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { orderBy } from "lodash";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVButton } from "@/components/tv";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||||
|
import { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal";
|
||||||
|
import {
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
interface TVSeasonToggleCardProps {
|
||||||
|
season: {
|
||||||
|
id: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeCount: number;
|
||||||
|
status: MediaStatus;
|
||||||
|
};
|
||||||
|
selected: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
canRequest: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
||||||
|
season,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
canRequest,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
|
// Get status icon and color based on MediaStatus
|
||||||
|
const getStatusIcon = (): {
|
||||||
|
icon: keyof typeof MaterialCommunityIcons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
} | null => {
|
||||||
|
switch (season.status) {
|
||||||
|
case MediaStatus.PROCESSING:
|
||||||
|
return { icon: "clock", color: "#6366f1" };
|
||||||
|
case MediaStatus.AVAILABLE:
|
||||||
|
return { icon: "check", color: "#22c55e" };
|
||||||
|
case MediaStatus.PENDING:
|
||||||
|
return { icon: "bell", color: "#eab308" };
|
||||||
|
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||||
|
return { icon: "minus", color: "#22c55e" };
|
||||||
|
case MediaStatus.BLACKLISTED:
|
||||||
|
return { icon: "eye-off", color: "#ef4444" };
|
||||||
|
default:
|
||||||
|
return canRequest ? { icon: "plus", color: "#22c55e" } : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusInfo = getStatusIcon();
|
||||||
|
const isDisabled = !canRequest;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={canRequest ? onToggle : undefined}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={isDisabled}
|
||||||
|
focusable={!isDisabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
styles.seasonCard,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#FFFFFF"
|
||||||
|
: selected
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderWidth: focused ? 0 : 1,
|
||||||
|
borderColor: selected
|
||||||
|
? "rgba(255,255,255,0.4)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Checkmark for selected */}
|
||||||
|
<View style={styles.checkmarkContainer}>
|
||||||
|
{selected && (
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-circle'
|
||||||
|
size={24}
|
||||||
|
color={focused ? "#22c55e" : "#FFFFFF"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Season info */}
|
||||||
|
<View style={styles.seasonInfo}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.seasonTitle,
|
||||||
|
{ color: focused ? "#000000" : "#FFFFFF" },
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.season_number", {
|
||||||
|
season_number: season.seasonNumber,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.episodeRow}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.episodeCount,
|
||||||
|
{
|
||||||
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.number_episodes", {
|
||||||
|
episode_number: season.episodeCount,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{statusInfo && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: statusInfo.color },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={statusInfo.icon}
|
||||||
|
size={14}
|
||||||
|
color='#FFFFFF'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TVSeasonSelectModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { requestMedia } = useJellyseerr();
|
||||||
|
const { showRequestModal } = useTVRequestModal();
|
||||||
|
|
||||||
|
// Selected seasons - initially select all requestable (UNKNOWN status) seasons
|
||||||
|
const [selectedSeasons, setSelectedSeasons] = useState<Set<number>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
// Initialize selected seasons when modal state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (modalState?.seasons) {
|
||||||
|
const requestableSeasons = modalState.seasons
|
||||||
|
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
|
||||||
|
.map((s) => s.seasonNumber);
|
||||||
|
setSelectedSeasons(new Set(requestableSeasons));
|
||||||
|
}
|
||||||
|
}, [modalState?.seasons]);
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
store.set(tvSeasonSelectModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Sort seasons by season number (ascending)
|
||||||
|
const sortedSeasons = useMemo(() => {
|
||||||
|
if (!modalState?.seasons) return [];
|
||||||
|
return orderBy(
|
||||||
|
modalState.seasons.filter((s) => s.seasonNumber !== 0),
|
||||||
|
"seasonNumber",
|
||||||
|
"asc",
|
||||||
|
);
|
||||||
|
}, [modalState?.seasons]);
|
||||||
|
|
||||||
|
// Find the index of the first requestable season for initial focus
|
||||||
|
const firstRequestableIndex = useMemo(() => {
|
||||||
|
return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN);
|
||||||
|
}, [sortedSeasons]);
|
||||||
|
|
||||||
|
const handleToggleSeason = useCallback((seasonNumber: number) => {
|
||||||
|
setSelectedSeasons((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(seasonNumber)) {
|
||||||
|
newSet.delete(seasonNumber);
|
||||||
|
} else {
|
||||||
|
newSet.add(seasonNumber);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRequestSelected = useCallback(() => {
|
||||||
|
if (!modalState || selectedSeasons.size === 0) return;
|
||||||
|
|
||||||
|
const seasonsArray = Array.from(selectedSeasons);
|
||||||
|
const body: MediaRequestBody = {
|
||||||
|
mediaId: modalState.mediaId,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: modalState.tvdbId,
|
||||||
|
seasons: seasonsArray,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (modalState.hasAdvancedRequestPermission) {
|
||||||
|
// Close this modal and open the advanced request modal
|
||||||
|
router.back();
|
||||||
|
showRequestModal({
|
||||||
|
requestBody: body,
|
||||||
|
title: modalState.title,
|
||||||
|
id: modalState.mediaId,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
onRequested: modalState.onRequested,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the title based on selected seasons
|
||||||
|
const seasonTitle =
|
||||||
|
seasonsArray.length === 1
|
||||||
|
? t("jellyseerr.season_number", { season_number: seasonsArray[0] })
|
||||||
|
: seasonsArray.length === sortedSeasons.length
|
||||||
|
? t("jellyseerr.season_all")
|
||||||
|
: t("jellyseerr.n_selected", { count: seasonsArray.length });
|
||||||
|
|
||||||
|
requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => {
|
||||||
|
modalState.onRequested();
|
||||||
|
router.back();
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
modalState,
|
||||||
|
selectedSeasons,
|
||||||
|
sortedSeasons.length,
|
||||||
|
requestMedia,
|
||||||
|
router,
|
||||||
|
t,
|
||||||
|
showRequestModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
|
{t("jellyseerr.select_seasons")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Season cards horizontal scroll */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{sortedSeasons.map((season, index) => {
|
||||||
|
const canRequestSeason = season.status === MediaStatus.UNKNOWN;
|
||||||
|
return (
|
||||||
|
<TVSeasonToggleCard
|
||||||
|
key={season.id}
|
||||||
|
season={season}
|
||||||
|
selected={selectedSeasons.has(season.seasonNumber)}
|
||||||
|
onToggle={() => handleToggleSeason(season.seasonNumber)}
|
||||||
|
canRequest={canRequestSeason}
|
||||||
|
hasTVPreferredFocus={index === firstRequestableIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Request button */}
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequestSelected}
|
||||||
|
variant='secondary'
|
||||||
|
disabled={selectedSeasons.size === 0}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.buttonText, { fontSize: typography.callout }]}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_selected")}
|
||||||
|
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 44,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
seasonCard: {
|
||||||
|
width: 160,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
},
|
||||||
|
checkmarkContainer: {
|
||||||
|
height: 24,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
seasonInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
seasonTitle: {
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
episodeRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
episodeCount: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
},
|
||||||
|
});
|
||||||
190
app/(auth)/tv-series-season-modal.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVCancelButton, TVOptionCard } from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
export default function TVSeriesSeasonModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const firstCardRef = useRef<View>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
const initialSelectedIndex = useMemo(() => {
|
||||||
|
if (!modalState?.seasons) return 0;
|
||||||
|
const idx = modalState.seasons.findIndex((o) => o.selected);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}, [modalState?.seasons]);
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
store.set(tvSeriesSeasonModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Focus on the selected card when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
const handleSelect = (seasonIndex: number) => {
|
||||||
|
if (modalState?.onSeasonSelect) {
|
||||||
|
modalState.onSeasonSelect(seasonIndex);
|
||||||
|
}
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={[styles.title, { fontSize: typography.callout }]}>
|
||||||
|
{t("item_card.select_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{modalState.seasons.map((season, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={season.value}
|
||||||
|
ref={
|
||||||
|
index === initialSelectedIndex ? firstCardRef : undefined
|
||||||
|
}
|
||||||
|
label={season.label}
|
||||||
|
selected={season.selected}
|
||||||
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||||
|
onPress={() => handleSelect(season.value)}
|
||||||
|
width={180}
|
||||||
|
height={85}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.cancelButtonContainer}>
|
||||||
|
<TVCancelButton
|
||||||
|
onPress={handleCancel}
|
||||||
|
label={t("common.cancel")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 20,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
cancelButtonContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
});
|
||||||
1274
app/(auth)/tv-subtitle-modal.tsx
Normal file
@@ -10,6 +10,7 @@ import * as BackgroundTask from "expo-background-task";
|
|||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
|
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||||
@@ -59,7 +60,7 @@ import { SystemBars } from "react-native-edge-to-edge";
|
|||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { store } from "@/utils/store";
|
import { store as jotaiStore, store } from "@/utils/store";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
@@ -178,7 +179,7 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<JotaiProvider>
|
<JotaiProvider store={jotaiStore}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<Layout />
|
<Layout />
|
||||||
@@ -428,6 +429,46 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name='+not-found' />
|
<Stack.Screen name='+not-found' />
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-option-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-subtitle-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-request-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-season-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-series-season-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Toaster
|
<Toaster
|
||||||
duration={4000}
|
duration={4000}
|
||||||
@@ -443,7 +484,7 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
<GlobalModal />
|
{!Platform.isTV && <GlobalModal />}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
662
app/login.tsx
@@ -1,659 +1,13 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Platform } from "react-native";
|
||||||
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { Login } from "@/components/login/Login";
|
||||||
import { Image } from "expo-image";
|
import { TVLogin } from "@/components/login/TVLogin";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Keyboard,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Switch,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
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({
|
const LoginPage: React.FC = () => {
|
||||||
username: z.string().min(1, t("login.username_required")),
|
if (Platform.isTV) {
|
||||||
});
|
return <TVLogin />;
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
const {
|
|
||||||
setServer,
|
|
||||||
login,
|
|
||||||
removeServer,
|
|
||||||
initiateQuickConnect,
|
|
||||||
loginWithSavedCredential,
|
|
||||||
loginWithPassword,
|
|
||||||
} = useJellyfin();
|
|
||||||
|
|
||||||
const {
|
|
||||||
apiUrl: _apiUrl,
|
|
||||||
username: _username,
|
|
||||||
password: _password,
|
|
||||||
} = params as { apiUrl: string; username: string; password: string };
|
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
|
||||||
const [serverName, setServerName] = useState<string>("");
|
|
||||||
const [credentials, setCredentials] = useState<{
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}>({
|
|
||||||
username: _username || "",
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (_apiUrl) {
|
|
||||||
await setServer({
|
|
||||||
address: _apiUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for server setup and state updates to complete
|
|
||||||
setTimeout(() => {
|
|
||||||
if (_username && _password) {
|
|
||||||
setCredentials({ username: _username, password: _password });
|
|
||||||
login(_username, _password);
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [_apiUrl, _username, _password]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerTitle: serverName,
|
|
||||||
headerLeft: () =>
|
|
||||||
api?.basePath ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeServer();
|
|
||||||
}}
|
|
||||||
className='flex flex-row items-center pr-2 pl-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
|
||||||
<Text className=' ml-1 text-purple-600'>
|
|
||||||
{t("login.change_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : null,
|
|
||||||
});
|
|
||||||
}, [serverName, navigation, api?.basePath]);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
await login(username, password, serverName, options);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
|
||||||
} else {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.connection_failed"),
|
|
||||||
t("login.an_unexpected_error_occured"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
|
||||||
*
|
|
||||||
* This function attempts to connect to a Jellyfin server using the provided URL.
|
|
||||||
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
|
|
||||||
*
|
|
||||||
* @param {string} url - The base URL of the Jellyfin server to check.
|
|
||||||
* @returns {Promise<string | undefined>} A Promise that resolves to:
|
|
||||||
* - The full URL (including protocol) if a valid Jellyfin server is found.
|
|
||||||
* - undefined if no valid server is found at the given URL.
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
|
||||||
* - Logs errors and timeout information to the console.
|
|
||||||
*/
|
|
||||||
const checkUrl = useCallback(async (url: string) => {
|
|
||||||
setLoadingServerCheck(true);
|
|
||||||
const baseUrl = url.replace(/^https?:\/\//i, "");
|
|
||||||
const protocols = ["https", "http"];
|
|
||||||
try {
|
|
||||||
return checkHttp(baseUrl, protocols);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message === "Server too old") {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
} finally {
|
|
||||||
setLoadingServerCheck(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function checkHttp(baseUrl: string, protocols: string[]) {
|
|
||||||
for (const protocol of protocols) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${protocol}://${baseUrl}/System/Info/Public`,
|
|
||||||
{
|
|
||||||
mode: "cors",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
|
||||||
const serverVersion = data.Version?.split(".");
|
|
||||||
if (serverVersion && +serverVersion[0] <= 10) {
|
|
||||||
if (+serverVersion[1] < 10) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.too_old_server_text"),
|
|
||||||
t("login.too_old_server_description"),
|
|
||||||
);
|
|
||||||
throw new Error("Server too old");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setServerName(data.ServerName || "");
|
|
||||||
return `${protocol}://${baseUrl}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message === "Server too old") {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
|
||||||
*
|
|
||||||
* This function trims the input URL, checks its validity using the `checkUrl` function,
|
|
||||||
* and sets the server address if a valid connection is established.
|
|
||||||
*
|
|
||||||
* @param {string} url - The URL of the Jellyfin server to connect to.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Calls `checkUrl` to validate the server URL.
|
|
||||||
* - Shows an alert if the connection fails.
|
|
||||||
* - Sets the server address using `setServer` if the connection is successful.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
const handleConnect = useCallback(async (url: string) => {
|
|
||||||
url = url.trim().replace(/\/$/, "");
|
|
||||||
try {
|
|
||||||
const result = await checkUrl(url);
|
|
||||||
if (result === undefined) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.connection_failed"),
|
|
||||||
t("login.could_not_connect_to_server"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await setServer({ address: result });
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
return <Login />;
|
||||||
try {
|
|
||||||
const code = await initiateQuickConnect();
|
|
||||||
if (code) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.quick_connect"),
|
|
||||||
t("login.enter_code_to_login", { code: code }),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("login.got_it"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.error_title"),
|
|
||||||
t("login.failed_to_initiate_quick_connect"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return Platform.isTV ? (
|
|
||||||
// TV layout
|
|
||||||
<SafeAreaView className='flex-1 bg-black'>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
{api?.basePath ? (
|
|
||||||
// ------------ Username/Password view ------------
|
|
||||||
<View className='flex-1 items-center justify-center'>
|
|
||||||
{/* Safe centered column with max width so TV doesn’t stretch too far */}
|
|
||||||
<View className='w-[92%] max-w-[900px] px-2 -mt-12'>
|
|
||||||
<Text className='text-3xl font-bold text-white mb-1'>
|
|
||||||
{serverName ? (
|
|
||||||
<>
|
|
||||||
{`${t("login.login_to_title")} `}
|
|
||||||
<Text className='text-purple-500'>{serverName}</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t("login.login_title")
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-xs text-neutral-400 mb-6'>
|
|
||||||
{api.basePath}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Username */}
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.username_placeholder")}
|
|
||||||
onChangeText={(text: string) =>
|
|
||||||
setCredentials((prev) => ({ ...prev, username: text }))
|
|
||||||
}
|
|
||||||
onEndEditing={(e) => {
|
|
||||||
const newValue = e.nativeEvent.text;
|
|
||||||
if (newValue && newValue !== credentials.username) {
|
|
||||||
setCredentials((prev) => ({ ...prev, username: newValue }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={credentials.username}
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
autoCorrect={false}
|
|
||||||
textContentType='username'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
extraClassName='mb-4'
|
|
||||||
autoFocus={false}
|
|
||||||
blurOnSubmit={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.password_placeholder")}
|
|
||||||
onChangeText={(text: string) =>
|
|
||||||
setCredentials((prev) => ({ ...prev, password: text }))
|
|
||||||
}
|
|
||||||
onEndEditing={(e) => {
|
|
||||||
const newValue = e.nativeEvent.text;
|
|
||||||
if (newValue && newValue !== credentials.password) {
|
|
||||||
setCredentials((prev) => ({ ...prev, password: newValue }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={credentials.password}
|
|
||||||
secureTextEntry
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='password'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
extraClassName='mb-4'
|
|
||||||
autoFocus={false}
|
|
||||||
blurOnSubmit={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View className='mt-4'>
|
|
||||||
<Button
|
|
||||||
onPress={handleLogin}
|
|
||||||
disabled={!credentials.username.trim()}
|
|
||||||
>
|
|
||||||
{t("login.login_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
<View className='mt-3'>
|
|
||||||
<Button
|
|
||||||
onPress={handleQuickConnect}
|
|
||||||
className='bg-neutral-800 border border-neutral-700'
|
|
||||||
>
|
|
||||||
{t("login.quick_connect")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
// ------------ Server connect view ------------
|
|
||||||
<View className='flex-1 items-center justify-center'>
|
|
||||||
<View className='w-[92%] max-w-[900px] -mt-2'>
|
|
||||||
<View className='items-center mb-1'>
|
|
||||||
<Image
|
|
||||||
source={require("@/assets/images/icon-ios-plain.png")}
|
|
||||||
style={{ width: 110, height: 110 }}
|
|
||||||
contentFit='contain'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className='text-white text-4xl font-bold text-center'>
|
|
||||||
Streamyfin
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-400 text-base text-left mt-2 mb-1'>
|
|
||||||
{t("server.enter_url_to_jellyfin_server")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Full-width Input with clear focus ring */}
|
|
||||||
<Input
|
|
||||||
aria-label='Server URL'
|
|
||||||
placeholder={t("server.server_url_placeholder")}
|
|
||||||
onChangeText={setServerURL}
|
|
||||||
value={serverURL}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
maxLength={500}
|
|
||||||
autoFocus={false}
|
|
||||||
blurOnSubmit={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Full-width primary button */}
|
|
||||||
<View className='mt-4'>
|
|
||||||
<Button
|
|
||||||
onPress={async () => {
|
|
||||||
await handleConnect(serverURL);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("server.connect_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Lists stay full width but inside max width container */}
|
|
||||||
<View className='mt-2'>
|
|
||||||
<JellyfinServerDiscovery
|
|
||||||
onServerSelect={async (server: any) => {
|
|
||||||
setServerURL(server.address);
|
|
||||||
if (server.serverName) setServerName(server.serverName);
|
|
||||||
await handleConnect(server.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PreviousServersList
|
|
||||||
onServerSelect={async (s) => {
|
|
||||||
await handleConnect(s.address);
|
|
||||||
}}
|
|
||||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
|
||||||
onPasswordLogin={handlePasswordLogin}
|
|
||||||
onAddAccount={handleAddAccount}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
|
||||||
) : (
|
|
||||||
// Mobile layout
|
|
||||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
{api?.basePath ? (
|
|
||||||
<View className='flex flex-col flex-1 justify-center'>
|
|
||||||
<View className='px-4 w-full'>
|
|
||||||
<View className='flex flex-col space-y-2'>
|
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
|
||||||
{serverName ? (
|
|
||||||
<>
|
|
||||||
{`${t("login.login_to_title")} `}
|
|
||||||
<Text className='text-purple-600'>{serverName}</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t("login.login_title")
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.username_placeholder")}
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials((prev) => ({ ...prev, username: text }))
|
|
||||||
}
|
|
||||||
onEndEditing={(e) => {
|
|
||||||
const newValue = e.nativeEvent.text;
|
|
||||||
if (newValue && newValue !== credentials.username) {
|
|
||||||
setCredentials((prev) => ({
|
|
||||||
...prev,
|
|
||||||
username: newValue,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={credentials.username}
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
autoCorrect={false}
|
|
||||||
textContentType='username'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.password_placeholder")}
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials((prev) => ({ ...prev, password: text }))
|
|
||||||
}
|
|
||||||
onEndEditing={(e) => {
|
|
||||||
const newValue = e.nativeEvent.text;
|
|
||||||
if (newValue && newValue !== credentials.password) {
|
|
||||||
setCredentials((prev) => ({
|
|
||||||
...prev,
|
|
||||||
password: newValue,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={credentials.password}
|
|
||||||
secureTextEntry
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='password'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setSaveAccount(!saveAccount)}
|
|
||||||
className='flex flex-row items-center py-2'
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={saveAccount}
|
|
||||||
onValueChange={setSaveAccount}
|
|
||||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
|
||||||
thumbColor='white'
|
|
||||||
/>
|
|
||||||
<Text className='ml-3 text-neutral-300'>
|
|
||||||
{t("save_account.save_for_later")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View className='flex flex-row items-center justify-between'>
|
|
||||||
<Button
|
|
||||||
onPress={handleLogin}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!credentials.username.trim()}
|
|
||||||
className='flex-1 mr-2'
|
|
||||||
>
|
|
||||||
{t("login.login_button")}
|
|
||||||
</Button>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleQuickConnect}
|
|
||||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='cellphone-lock'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
|
||||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
|
||||||
<Image
|
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
marginLeft: -23,
|
|
||||||
marginBottom: -20,
|
|
||||||
}}
|
|
||||||
source={require("@/assets/images/icon-ios-plain.png")}
|
|
||||||
/>
|
|
||||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
|
||||||
<Text className='text-neutral-500'>
|
|
||||||
{t("server.enter_url_to_jellyfin_server")}
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
aria-label='Server URL'
|
|
||||||
placeholder={t("server.server_url_placeholder")}
|
|
||||||
onChangeText={setServerURL}
|
|
||||||
value={serverURL}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
loading={loadingServerCheck}
|
|
||||||
disabled={loadingServerCheck}
|
|
||||||
onPress={async () => {
|
|
||||||
await handleConnect(serverURL);
|
|
||||||
}}
|
|
||||||
className='w-full grow'
|
|
||||||
>
|
|
||||||
{t("server.connect_button")}
|
|
||||||
</Button>
|
|
||||||
<JellyfinServerDiscovery
|
|
||||||
onServerSelect={async (server) => {
|
|
||||||
setServerURL(server.address);
|
|
||||||
if (server.serverName) {
|
|
||||||
setServerName(server.serverName);
|
|
||||||
}
|
|
||||||
await handleConnect(server.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PreviousServersList
|
|
||||||
onServerSelect={async (s) => {
|
|
||||||
await handleConnect(s.address);
|
|
||||||
}}
|
|
||||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
|
||||||
onPasswordLogin={handlePasswordLogin}
|
|
||||||
onAddAccount={handleAddAccount}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
|
|
||||||
{/* Save Account Modal */}
|
|
||||||
<SaveAccountModal
|
|
||||||
visible={showSaveModal}
|
|
||||||
onClose={() => {
|
|
||||||
setShowSaveModal(false);
|
|
||||||
setPendingLogin(null);
|
|
||||||
}}
|
|
||||||
onSave={handleSaveAccountConfirm}
|
|
||||||
username={pendingLogin?.username || credentials.username}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Login;
|
export default LoginPage;
|
||||||
|
|||||||
BIN
assets/images/icon-tvos-small-2x.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
assets/images/icon-tvos-small.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/images/icon-tvos-topshelf-2x.png
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
assets/images/icon-tvos-topshelf-wide-2x.png
Normal file
|
After Width: | Height: | Size: 417 KiB |
BIN
assets/images/icon-tvos-topshelf-wide.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
assets/images/icon-tvos-topshelf.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/images/icon-tvos.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
248
bun.lock
@@ -19,7 +19,7 @@
|
|||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.90.20",
|
"@tanstack/react-query": "5.90.17",
|
||||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
@@ -52,13 +52,13 @@
|
|||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"i18next": "^25.0.0",
|
"i18next": "^25.0.0",
|
||||||
"jotai": "2.16.2",
|
"jotai": "2.16.2",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "16.5.3",
|
||||||
"react-native": "0.81.5",
|
"react-native": "npm:react-native-tvos@0.81.5-2",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.1.0",
|
"react-native-bottom-tabs": "1.1.0",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
@@ -97,16 +97,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.6",
|
"@babel/core": "7.28.6",
|
||||||
"@biomejs/biome": "2.3.11",
|
"@biomejs/biome": "2.3.11",
|
||||||
"@react-native-community/cli": "20.1.3",
|
"@react-native-community/cli": "20.1.0",
|
||||||
"@react-native-tvos/config-tv": "0.1.6",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.23",
|
"@types/lodash": "4.17.23",
|
||||||
"@types/react": "19.1.17",
|
"@types/react": "19.1.17",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.18.22",
|
"expo-doctor": "1.17.14",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "16.2.7",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
|
|
||||||
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
|
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
|
||||||
|
|
||||||
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||||
|
|
||||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||||
|
|
||||||
@@ -462,8 +462,6 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||||
@@ -514,33 +512,35 @@
|
|||||||
|
|
||||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||||
|
|
||||||
"@react-native-community/cli": ["@react-native-community/cli@20.1.3", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.3", "@react-native-community/cli-config": "20.1.3", "@react-native-community/cli-doctor": "20.1.3", "@react-native-community/cli-server-api": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "@react-native-community/cli-types": "20.1.3", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-sLo8cu9JyFNfuuF1C+8NJ4DHE/PEFaXGd4enkcxi/OJjGG8+sOQrdjNQ4i+cVh/2c+ah1mEMwsYjc3z0+/MqSg=="],
|
"@react-native-community/cli": ["@react-native-community/cli@20.1.0", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.0", "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-doctor": "20.1.0", "@react-native-community/cli-server-api": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "@react-native-community/cli-types": "20.1.0", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg=="],
|
||||||
|
|
||||||
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-sFLdLzapfC0scjgzBJJWYDY2RhHPjuuPkA5r6q0gc/UQH/izXpMpLrhh1DW84cMDraNACK0U62tU7ebNaQ1LMQ=="],
|
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-n73nW0cG92oNF0r994pPqm0DjAShOm3F8LSffDYhJqNAno+h/csmv/37iL4NtSpmKIO8xqsG3uVTXz9X/hzNaQ=="],
|
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag=="],
|
||||||
|
|
||||||
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "fast-glob": "^3.3.2", "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "sha512-DNHDP+OWLyhKShGciBqPcxhxfp1Z/7GQcb4F+TGyCeKQAr+JdnUjRXN3X+YCU/v+g2kbYYyRJKlGabzkVvdrAw=="],
|
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-QX9B83nAfCPs0KiaYz61kAEHWr9sttooxzRzNdQwvZTwnsIpvWOT9GvMMj/19OeXiQzMJBzZX0Pgt6+spiUsDQ=="],
|
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.3", "", { "dependencies": { "@react-native-community/cli-config": "20.1.3", "@react-native-community/cli-platform-android": "20.1.3", "@react-native-community/cli-platform-apple": "20.1.3", "@react-native-community/cli-platform-ios": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-EI+mAPWn255/WZ4CQohy1I049yiaxVr41C3BeQ2BCyhxODIDR8XRsLzYb1t9MfqK/C3ZncUN2mPSRXFeKPPI1w=="],
|
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.0", "", { "dependencies": { "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", "@react-native-community/cli-platform-apple": "20.1.0", "@react-native-community/cli-platform-ios": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ=="],
|
||||||
|
|
||||||
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.3", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-bzB9ELPOISuqgtDZXFPQlkuxx1YFkNx3cNgslc5ElCrk+5LeCLQLIBh/dmIuK8rwUrPcrramjeBj++Noc+TaAA=="],
|
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg=="],
|
||||||
|
|
||||||
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.3", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "sha512-XJ+DqAD4hkplWVXK5AMgN7pP9+4yRSe5KfZ/b42+ofkDBI55ALlUmX+9HWE3fMuRjcotTCoNZqX2ov97cFDXpQ=="],
|
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg=="],
|
||||||
|
|
||||||
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.3", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.3" } }, "sha512-2qL48SINotuHbZO73cgqSwqd/OWNx0xTbFSdujhpogV4p8BNwYYypfjh4vJY5qJEB5PxuoVkMXT+aCADpg9nBg=="],
|
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.0", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.0" } }, "sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "body-parser": "^2.2.2", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "sha512-hsNsdUKZDd2T99OuNuiXz4VuvLa1UN0zcxefmPjXQgI0byrBLzzDr+o7p03sKuODSzKi2h+BMnUxiS07HACQLA=="],
|
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.3", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-EAn0vPCMxtHhfWk2UwLmSUfPfLUnFgC7NjiVJVTKJyVk5qGnkPfoT8te/1IUXFTysUB0F0RIi+NgDB4usFOLeA=="],
|
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.0", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ=="],
|
||||||
|
|
||||||
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.3", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-IdAcegf0pH1hVraxWTG1ACLkYC0LDQfqtaEf42ESyLIF3Xap70JzL/9tAlxw7lSCPZPFWhrcgU0TBc4SkC/ecw=="],
|
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.0", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA=="],
|
||||||
|
|
||||||
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
|
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
|
||||||
|
|
||||||
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.6", "", { "dependencies": { "getenv": "^1.0.0", "glob": "^11.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-VxMSIcro+U1EVb64pYShZsc+uE3HNGhfHppoUhTyGwx9ELQkhWvReRTOI4gpb/qeRWEcT+UbUc9Gd9Zlwm572w=="],
|
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
|
||||||
|
|
||||||
|
"@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-2", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-i5L6sJ8Dae5JUWhfb5w/RgZUm3CYRFhV5/PB/xu3ASxFyHjfO0kQAqcU3ySNAOR0HfmaXK8R4OC0h07zoUWKrQ=="],
|
||||||
|
|
||||||
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
|
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
|
||||||
|
|
||||||
@@ -562,8 +562,6 @@
|
|||||||
|
|
||||||
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
|
"@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/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/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=="],
|
||||||
@@ -604,7 +602,7 @@
|
|||||||
|
|
||||||
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
|
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
|
||||||
|
|
||||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
|
"@tanstack/react-query": ["@tanstack/react-query@5.90.17", "", { "dependencies": { "@tanstack/query-core": "5.90.17" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ=="],
|
||||||
|
|
||||||
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
|
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
|
||||||
|
|
||||||
@@ -776,7 +774,7 @@
|
|||||||
|
|
||||||
"bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="],
|
"bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
|
||||||
|
|
||||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||||
|
|
||||||
@@ -830,7 +828,7 @@
|
|||||||
|
|
||||||
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||||
|
|
||||||
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
|
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
|
||||||
|
|
||||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||||
|
|
||||||
@@ -846,7 +844,7 @@
|
|||||||
|
|
||||||
"color-string": ["color-string@2.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA=="],
|
"color-string": ["color-string@2.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA=="],
|
||||||
|
|
||||||
"colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
|
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||||
|
|
||||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
@@ -992,7 +990,7 @@
|
|||||||
|
|
||||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||||
|
|
||||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||||
|
|
||||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||||
|
|
||||||
@@ -1032,7 +1030,7 @@
|
|||||||
|
|
||||||
"expo-device": ["expo-device@8.0.10", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA=="],
|
"expo-device": ["expo-device@8.0.10", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA=="],
|
||||||
|
|
||||||
"expo-doctor": ["expo-doctor@1.18.22", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-AEGwceidWxyYpWEfIf3XnUvc+FbI3OjjyBaXctuoZg10x9An+utrdRf6go/3UFRAG5EkpMOWgUT0j1TKcYDsSQ=="],
|
"expo-doctor": ["expo-doctor@1.17.14", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-+UsXFP5ZTVobDuGS5Du8aKU6O6s2sa49QOdGHdzP8UEjQKH8gPb59uw6hxEQmo6YtVboLwQd13QEdcSolBMvLw=="],
|
||||||
|
|
||||||
"expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="],
|
"expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="],
|
||||||
|
|
||||||
@@ -1096,9 +1094,7 @@
|
|||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||||
|
|
||||||
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
|
||||||
|
|
||||||
"fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="],
|
|
||||||
|
|
||||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||||
|
|
||||||
@@ -1152,7 +1148,7 @@
|
|||||||
|
|
||||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
@@ -1168,7 +1164,7 @@
|
|||||||
|
|
||||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||||
|
|
||||||
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
@@ -1210,7 +1206,7 @@
|
|||||||
|
|
||||||
"i18next": ["i18next@25.6.1", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-yUWvdXtalZztmKrKw3yz/AvSP3yKyqIkVPx/wyvoYy9lkLmwzItLxp0iHZLG5hfVQ539Jor4XLO+U+NHIXg7pw=="],
|
"i18next": ["i18next@25.6.1", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-yUWvdXtalZztmKrKw3yz/AvSP3yKyqIkVPx/wyvoYy9lkLmwzItLxp0iHZLG5hfVQ539Jor4XLO+U+NHIXg7pw=="],
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
@@ -1280,7 +1276,7 @@
|
|||||||
|
|
||||||
"istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="],
|
"istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="],
|
||||||
|
|
||||||
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
|
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||||
|
|
||||||
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
|
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
|
||||||
|
|
||||||
@@ -1374,13 +1370,13 @@
|
|||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"lint-staged": ["lint-staged@17.0.5", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw=="],
|
"lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="],
|
||||||
|
|
||||||
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
"listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="],
|
||||||
|
|
||||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||||
|
|
||||||
@@ -1404,7 +1400,7 @@
|
|||||||
|
|
||||||
"mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
|
"mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
|
||||||
|
|
||||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||||
|
|
||||||
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
|
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
|
||||||
|
|
||||||
@@ -1452,7 +1448,7 @@
|
|||||||
|
|
||||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
@@ -1466,6 +1462,8 @@
|
|||||||
|
|
||||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
|
|
||||||
|
"nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
"nativewind": ["nativewind@2.0.11", "", { "dependencies": { "@babel/generator": "^7.18.7", "@babel/helper-module-imports": "7.18.6", "@babel/types": "7.19.0", "css-mediaquery": "^0.1.2", "css-to-react-native": "^3.0.0", "micromatch": "^4.0.5", "postcss": "^8.4.12", "postcss-calc": "^8.2.4", "postcss-color-functional-notation": "^4.2.2", "postcss-css-variables": "^0.18.0", "postcss-nested": "^5.0.6", "react-is": "^18.1.0", "use-sync-external-store": "^1.1.0" }, "peerDependencies": { "tailwindcss": "~3" } }, "sha512-qCEXUwKW21RYJ33KRAJl3zXq2bCq82WoI564fI21D/TiqhfmstZOqPN53RF8qK1NDK6PGl56b2xaTxgObEePEg=="],
|
"nativewind": ["nativewind@2.0.11", "", { "dependencies": { "@babel/generator": "^7.18.7", "@babel/helper-module-imports": "7.18.6", "@babel/types": "7.19.0", "css-mediaquery": "^0.1.2", "css-to-react-native": "^3.0.0", "micromatch": "^4.0.5", "postcss": "^8.4.12", "postcss-calc": "^8.2.4", "postcss-color-functional-notation": "^4.2.2", "postcss-css-variables": "^0.18.0", "postcss-nested": "^5.0.6", "react-is": "^18.1.0", "use-sync-external-store": "^1.1.0" }, "peerDependencies": { "tailwindcss": "~3" } }, "sha512-qCEXUwKW21RYJ33KRAJl3zXq2bCq82WoI564fI21D/TiqhfmstZOqPN53RF8qK1NDK6PGl56b2xaTxgObEePEg=="],
|
||||||
@@ -1548,8 +1546,6 @@
|
|||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
|
||||||
|
|
||||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
@@ -1562,7 +1558,9 @@
|
|||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
|
||||||
|
|
||||||
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||||
|
|
||||||
@@ -1618,7 +1616,7 @@
|
|||||||
|
|
||||||
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
|
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
|
||||||
|
|
||||||
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
|
||||||
|
|
||||||
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
||||||
|
|
||||||
@@ -1628,7 +1626,7 @@
|
|||||||
|
|
||||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="],
|
||||||
|
|
||||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||||
|
|
||||||
@@ -1642,11 +1640,11 @@
|
|||||||
|
|
||||||
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
||||||
|
|
||||||
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
"react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="],
|
||||||
|
|
||||||
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||||
|
|
||||||
"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": ["react-native-tvos@0.81.5-2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-2", "@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-y/V8iFZGNXQq6b+X9VBQG19PaBpAXQHhv2vhcCMe2gEePqI2Uu8n3ClqglBn8u+Fl/GXCMcFdnJ0v0nRyxJ5TA=="],
|
||||||
|
|
||||||
"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-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=="],
|
||||||
|
|
||||||
@@ -1698,7 +1696,7 @@
|
|||||||
|
|
||||||
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
||||||
|
|
||||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#003afd0", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-003afd0", "sha512-HR7BaMDMBhQ4xAy5XeQEkT0fBosWw2x9+X2QOD4buocxuX03D770LaRKm5rgQ/qDzchr92KW7+d8fISI14fRLA=="],
|
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#003afd0", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-003afd0"],
|
||||||
|
|
||||||
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
||||||
|
|
||||||
@@ -1828,7 +1826,7 @@
|
|||||||
|
|
||||||
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
|
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
|
||||||
|
|
||||||
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
|
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||||
|
|
||||||
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
|
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
|
||||||
|
|
||||||
@@ -1856,8 +1854,6 @@
|
|||||||
|
|
||||||
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
|
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
|
||||||
|
|
||||||
"strict-url-sanitise": ["strict-url-sanitise@0.0.1", "", {}, "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg=="],
|
|
||||||
|
|
||||||
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
||||||
|
|
||||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
@@ -1874,7 +1870,7 @@
|
|||||||
|
|
||||||
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||||
|
|
||||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
"strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
|
||||||
|
|
||||||
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
|
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
|
||||||
|
|
||||||
@@ -1912,8 +1908,6 @@
|
|||||||
|
|
||||||
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
|
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
|
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||||
@@ -1938,7 +1932,7 @@
|
|||||||
|
|
||||||
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
||||||
|
|
||||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
@@ -2018,7 +2012,7 @@
|
|||||||
|
|
||||||
"wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="],
|
"wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
|
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
@@ -2030,8 +2024,6 @@
|
|||||||
|
|
||||||
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
|
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
|
||||||
|
|
||||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
|
||||||
|
|
||||||
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
|
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
|
||||||
|
|
||||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||||
@@ -2040,7 +2032,7 @@
|
|||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
||||||
|
|
||||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||||
|
|
||||||
@@ -2124,8 +2116,6 @@
|
|||||||
|
|
||||||
"@expo/cli/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
"@expo/cli/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||||
|
|
||||||
"@expo/cli/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
|
||||||
|
|
||||||
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
|
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
|
||||||
|
|
||||||
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
|
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
|
||||||
@@ -2162,8 +2152,6 @@
|
|||||||
|
|
||||||
"@expo/fingerprint/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
"@expo/fingerprint/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||||
|
|
||||||
"@expo/fingerprint/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
|
||||||
|
|
||||||
"@expo/fingerprint/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"@expo/fingerprint/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|
||||||
"@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
"@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||||
@@ -2182,8 +2170,6 @@
|
|||||||
|
|
||||||
"@expo/metro-config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
"@expo/metro-config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||||
|
|
||||||
"@expo/metro-config/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
|
||||||
|
|
||||||
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
||||||
|
|
||||||
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
|
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
|
||||||
@@ -2192,6 +2178,12 @@
|
|||||||
|
|
||||||
"@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
|
"@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
|
|
||||||
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
"@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
"@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
@@ -2228,8 +2220,6 @@
|
|||||||
|
|
||||||
"@react-native/codegen/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
"@react-native/codegen/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
||||||
|
|
||||||
"@react-native/codegen/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="],
|
"@react-native/community-cli-plugin/metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="],
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="],
|
"@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="],
|
||||||
@@ -2252,7 +2242,7 @@
|
|||||||
|
|
||||||
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||||
|
|
||||||
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="],
|
||||||
|
|
||||||
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
||||||
|
|
||||||
@@ -2268,12 +2258,12 @@
|
|||||||
|
|
||||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||||
|
|
||||||
|
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="],
|
"ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="],
|
||||||
|
|
||||||
"ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
|
"ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
|
||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
|
||||||
|
|
||||||
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||||
|
|
||||||
"babel-plugin-jest-hoist/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
"babel-plugin-jest-hoist/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||||
@@ -2288,11 +2278,13 @@
|
|||||||
|
|
||||||
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||||
|
|
||||||
|
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
|
"cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
|
||||||
|
|
||||||
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
@@ -2328,6 +2320,8 @@
|
|||||||
|
|
||||||
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
|
"glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
|
|
||||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
@@ -2344,20 +2338,16 @@
|
|||||||
|
|
||||||
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||||
|
|
||||||
"jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
|
||||||
|
|
||||||
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||||
|
|
||||||
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
|
"lint-staged/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||||
|
|
||||||
"log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
"log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||||
|
|
||||||
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
|
||||||
|
|
||||||
"log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
"log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
|
||||||
|
|
||||||
"logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
"logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||||
|
|
||||||
"metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
"metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||||
@@ -2384,8 +2374,6 @@
|
|||||||
|
|
||||||
"metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
|
"metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
|
||||||
|
|
||||||
"metro-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
|
||||||
|
|
||||||
"metro-source-map/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
"metro-source-map/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
||||||
|
|
||||||
"metro-source-map/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
"metro-source-map/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
||||||
@@ -2406,8 +2394,6 @@
|
|||||||
|
|
||||||
"metro-transform-worker/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
"metro-transform-worker/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
|
||||||
|
|
||||||
"nativewind/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
|
"nativewind/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
|
||||||
|
|
||||||
"nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="],
|
"nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="],
|
||||||
@@ -2424,32 +2410,22 @@
|
|||||||
|
|
||||||
"patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
"patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||||
|
|
||||||
"patch-package/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
|
||||||
|
|
||||||
"path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
"path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
||||||
|
|
||||||
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||||
|
|
||||||
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
"postcss-load-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
|
||||||
|
|
||||||
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||||
|
|
||||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
"raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
|
||||||
|
|
||||||
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||||
|
|
||||||
"react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
"react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||||
|
|
||||||
"react-i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
|
||||||
|
|
||||||
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||||
|
|
||||||
"react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
|
||||||
|
|
||||||
"react-native/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
"react-native/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||||
|
|
||||||
"react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
@@ -2464,12 +2440,8 @@
|
|||||||
|
|
||||||
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||||
|
|
||||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
|
||||||
|
|
||||||
"requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="],
|
"requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="],
|
||||||
|
|
||||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
|
||||||
|
|
||||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||||
@@ -2498,21 +2470,15 @@
|
|||||||
|
|
||||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||||
|
|
||||||
"test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
|
||||||
|
|
||||||
"test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
"test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
|
|
||||||
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
|
||||||
|
|
||||||
"type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
|
||||||
|
|
||||||
"whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
|
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
@@ -2672,6 +2638,10 @@
|
|||||||
|
|
||||||
"@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"@expo/config-plugins/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||||
|
|
||||||
|
"@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||||
|
|
||||||
"@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
"@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
"@expo/fingerprint/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
"@expo/fingerprint/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||||
@@ -2704,6 +2674,12 @@
|
|||||||
|
|
||||||
"@expo/package-manager/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
|
"@expo/package-manager/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
||||||
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
@@ -2778,8 +2754,6 @@
|
|||||||
|
|
||||||
"@react-native/codegen/@babel/parser/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
"@react-native/codegen/@babel/parser/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
||||||
|
|
||||||
"@react-native/codegen/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
"@react-native/community-cli-plugin/metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/metro/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
|
"@react-native/community-cli-plugin/metro/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
|
||||||
@@ -2824,8 +2798,6 @@
|
|||||||
|
|
||||||
"@react-native/community-cli-plugin/metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
|
"@react-native/community-cli-plugin/metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/metro-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="],
|
"@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="],
|
||||||
|
|
||||||
"@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
@@ -2858,6 +2830,8 @@
|
|||||||
|
|
||||||
"babel-preset-expo/@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
"babel-preset-expo/@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
||||||
|
|
||||||
|
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
@@ -2884,6 +2858,8 @@
|
|||||||
|
|
||||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
|
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"istanbul-lib-instrument/@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
"istanbul-lib-instrument/@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||||
|
|
||||||
"istanbul-lib-instrument/@babel/core/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
|
"istanbul-lib-instrument/@babel/core/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
|
||||||
@@ -2906,16 +2882,8 @@
|
|||||||
|
|
||||||
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||||
|
|
||||||
"log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
|
||||||
|
|
||||||
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
|
||||||
|
|
||||||
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
"log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
|
||||||
|
|
||||||
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
|
||||||
|
|
||||||
"logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
"logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||||
|
|
||||||
"logkitty/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
"logkitty/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
@@ -3010,14 +2978,8 @@
|
|||||||
|
|
||||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
|
||||||
|
|
||||||
"react-native/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
|
||||||
|
|
||||||
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||||
|
|
||||||
"rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
|
||||||
|
|
||||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
@@ -3026,22 +2988,16 @@
|
|||||||
|
|
||||||
"serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
"serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||||
|
|
||||||
"slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
|
||||||
|
|
||||||
"sucrase/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
|
||||||
|
|
||||||
"sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
|
||||||
|
|
||||||
"sucrase/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
"sucrase/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
"terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
"terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||||
|
|
||||||
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
|
||||||
|
|
||||||
"wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
@@ -3124,8 +3080,6 @@
|
|||||||
|
|
||||||
"@react-native/codegen/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
"@react-native/codegen/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
||||||
|
|
||||||
"@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
|
"@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
|
"@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
|
||||||
@@ -3174,6 +3128,8 @@
|
|||||||
|
|
||||||
"expo-manifests/@expo/config/@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
"expo-manifests/@expo/config/@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||||
|
|
||||||
|
"expo-manifests/@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||||
|
|
||||||
"expo-manifests/@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
"expo-manifests/@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
"istanbul-lib-instrument/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
|
"istanbul-lib-instrument/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
|
||||||
@@ -3184,12 +3140,6 @@
|
|||||||
|
|
||||||
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
"log-update/slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
|
||||||
|
|
||||||
"log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
|
||||||
|
|
||||||
"log-update/wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
|
||||||
|
|
||||||
"logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
"logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
"logkitty/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"logkitty/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
@@ -3212,14 +3162,8 @@
|
|||||||
|
|
||||||
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
||||||
|
|
||||||
"react-native/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
|
||||||
|
|
||||||
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
|
||||||
|
|
||||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"sucrase/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
|
||||||
|
|
||||||
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
@@ -3254,12 +3198,6 @@
|
|||||||
|
|
||||||
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
"sucrase/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
|
||||||
|
|
||||||
"sucrase/glob/jackspeak/@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
|
||||||
|
|
||||||
"sucrase/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
|
||||||
|
|
||||||
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||||
|
|
||||||
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
|
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
|
||||||
@@ -3272,12 +3210,6 @@
|
|||||||
|
|
||||||
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
|
|
||||||
"sucrase/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
|
||||||
|
|
||||||
"sucrase/glob/jackspeak/@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
|
||||||
|
|
||||||
"sucrase/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
|
||||||
|
|
||||||
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -14,6 +16,8 @@ export const Badge: React.FC<Props> = ({
|
|||||||
variant = "purple",
|
variant = "purple",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||||
@@ -28,7 +32,7 @@ export const Badge: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
<View {...props} style={[styles.container, props.style]}>
|
<View {...props} style={[styles.container, props.style]}>
|
||||||
<GlassEffectView style={{ borderRadius: 100 }}>
|
<GlassEffectView style={{ borderRadius: 100 }}>
|
||||||
@@ -38,21 +42,70 @@ export const Badge: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On TV, use BlurView for consistent styling
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<BlurView
|
||||||
|
intensity={10}
|
||||||
|
tint='light'
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
flexShrink: 1,
|
||||||
|
flexGrow: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
|
},
|
||||||
|
props.style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#E5E7EB",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
{...props}
|
{...props}
|
||||||
className={`
|
style={[
|
||||||
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
|
{
|
||||||
${variant === "purple" && "bg-purple-600"}
|
borderRadius: 4,
|
||||||
${variant === "gray" && "bg-neutral-800"}
|
padding: 4,
|
||||||
`}
|
paddingHorizontal: 6,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexGrow: 0,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: variant === "purple" ? "#9333ea" : "#262626",
|
||||||
|
},
|
||||||
|
props.style,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
|
{iconLeft && <View style={{ marginRight: 4 }}>{iconLeft}</View>}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
style={{
|
||||||
text-xs
|
fontSize: 12,
|
||||||
${variant === "purple" && "text-white"}
|
color: "#fff",
|
||||||
`}
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
animateTo(1.08);
|
animateTo(1.03);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
@@ -132,10 +132,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
shadowColor: "#a855f7",
|
shadowColor: color === "black" ? "#ffffff" : "#a855f7",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.9 : 0,
|
shadowOpacity: focused ? 0.5 : 0,
|
||||||
shadowRadius: focused ? 18 : 0,
|
shadowRadius: focused ? 10 : 0,
|
||||||
elevation: focused ? 12 : 0, // Android glow
|
elevation: focused ? 12 : 0, // Android glow
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
188
components/ContinueWatchingPoster.tv.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ProgressBar } from "./common/ProgressBar";
|
||||||
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
|
||||||
|
export const TV_LANDSCAPE_WIDTH = 400;
|
||||||
|
|
||||||
|
type ContinueWatchingPosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
useEpisodePoster?: boolean;
|
||||||
|
size?: "small" | "normal";
|
||||||
|
showPlayButton?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||||
|
item,
|
||||||
|
useEpisodePoster = false,
|
||||||
|
// TV version uses fixed width, size prop kept for API compatibility
|
||||||
|
size: _size = "normal",
|
||||||
|
showPlayButton = false,
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode" && useEpisodePoster) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Movie") {
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
|
}
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
|
}
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}, [api, item, useEpisodePoster]);
|
||||||
|
|
||||||
|
const progress = useMemo(() => {
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
if (!item.StartDate || !item.EndDate) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const startDate = new Date(item.StartDate);
|
||||||
|
const endDate = new Date(item.EndDate);
|
||||||
|
const now = new Date();
|
||||||
|
const total = endDate.getTime() - startDate.getTime();
|
||||||
|
if (total <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const elapsed = now.getTime() - startDate.getTime();
|
||||||
|
return (elapsed / total) * 100;
|
||||||
|
}
|
||||||
|
return item.UserData?.PlayedPercentage || 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const isWatched = item.UserData?.Played === true;
|
||||||
|
|
||||||
|
// Use glass effect on tvOS 26+
|
||||||
|
const useGlass = isGlassEffectAvailable();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_LANDSCAPE_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useGlass) {
|
||||||
|
return (
|
||||||
|
<View style={{ position: "relative" }}>
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={url}
|
||||||
|
aspectRatio={16 / 9}
|
||||||
|
cornerRadius={24}
|
||||||
|
progress={progress}
|
||||||
|
showWatchedIndicator={isWatched}
|
||||||
|
isFocused={false}
|
||||||
|
width={TV_LANDSCAPE_WIDTH}
|
||||||
|
style={{ width: TV_LANDSCAPE_WIDTH }}
|
||||||
|
/>
|
||||||
|
{showPlayButton && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-circle' size={56} color='white' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for older tvOS versions
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: TV_LANDSCAPE_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={{
|
||||||
|
uri: url,
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showPlayButton && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-circle' size={56} color='white' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
<ProgressBar item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContinueWatchingPoster;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// GenreTags.tsx
|
// GenreTags.tsx
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
type ViewProps,
|
type ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
@@ -23,7 +25,10 @@ export const Tag: React.FC<
|
|||||||
textStyle?: StyleProp<TextStyle>;
|
textStyle?: StyleProp<TextStyle>;
|
||||||
} & ViewProps
|
} & ViewProps
|
||||||
> = ({ text, textClass, textStyle, ...props }) => {
|
> = ({ text, textClass, textStyle, ...props }) => {
|
||||||
if (Platform.OS === "ios") {
|
// Hook must be called at the top level, before any conditional returns
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<GlassEffectView style={styles.glass}>
|
<GlassEffectView style={styles.glass}>
|
||||||
@@ -40,6 +45,32 @@ export const Tag: React.FC<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TV-specific styling with blur background
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<BlurView
|
||||||
|
intensity={10}
|
||||||
|
tint='light'
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#E5E7EB" }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
||||||
<Text className={textClass} style={textStyle}>
|
<Text className={textClass} style={textStyle}>
|
||||||
@@ -66,7 +97,8 @@ export const Tags: React.FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row flex-wrap gap-1 ${props.className}`}
|
className={`flex flex-row flex-wrap ${props.className}`}
|
||||||
|
style={{ gap: Platform.isTV ? 12 : 4 }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{tags.map((tag, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
|
|||||||
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
@@ -36,6 +35,9 @@ import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
|||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
const ItemContentTV = Platform.isTV
|
||||||
|
? require("./ItemContent.tv").ItemContentTV
|
||||||
|
: null;
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -45,229 +47,243 @@ export type SelectedOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ItemContentProps {
|
interface ItemContentProps {
|
||||||
item: BaseItemDto;
|
item?: BaseItemDto | null;
|
||||||
itemWithSources?: BaseItemDto | null;
|
itemWithSources?: BaseItemDto | null;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
// Mobile-specific implementation
|
||||||
({ item, itemWithSources }) => {
|
const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||||
const [api] = useAtom(apiAtom);
|
item,
|
||||||
const isOffline = useOfflineMode();
|
itemWithSources,
|
||||||
const { settings } = useSettings();
|
}) => {
|
||||||
const { orientation } = useOrientation();
|
const [api] = useAtom(apiAtom);
|
||||||
const navigation = useNavigation();
|
const isOffline = useOfflineMode();
|
||||||
const insets = useSafeAreaInsets();
|
const { settings } = useSettings();
|
||||||
const [user] = useAtom(userAtom);
|
const { orientation } = useOrientation();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const itemColors = useImageColorsReturn({ item });
|
const itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
// Use itemWithSources for play settings since it has MediaSources data
|
// Use itemWithSources for play settings since it has MediaSources data
|
||||||
const {
|
const {
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
[api, item],
|
[api, item],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onLogoLoad = React.useCallback(() => {
|
const onLogoLoad = React.useCallback(() => {
|
||||||
setLoadingLogo(false);
|
setLoadingLogo(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
// Needs to automatically change the selected to the default values for default indexes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
}, [
|
}, [
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV && itemWithSources) {
|
if (!Platform.isTV && itemWithSources) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item &&
|
||||||
(Platform.OS === "ios" ? (
|
(Platform.OS === "ios" ? (
|
||||||
<View className='flex flex-row items-center pl-2'>
|
<View className='flex flex-row items-center pl-2'>
|
||||||
<Chromecast.Chromecast width={22} height={22} />
|
<Chromecast.Chromecast width={22} height={22} />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center'>
|
<View className='flex flex-row items-center'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||||
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator &&
|
||||||
|
!settings.hideRemoteSessionButton && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
)}
|
)}
|
||||||
{user?.Policy?.IsAdministrator &&
|
|
||||||
!settings.hideRemoteSessionButton && (
|
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
{settings.streamyStatsServerUrl &&
|
{settings.streamyStatsServerUrl &&
|
||||||
!settings.hideWatchlistsTab && (
|
!settings.hideWatchlistsTab && (
|
||||||
<AddToWatchlist item={item} />
|
<AddToWatchlist item={item} />
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
|
||||||
<Chromecast.Chromecast width={22} height={22} />
|
|
||||||
{item.Type !== "Program" && (
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
|
||||||
)}
|
)}
|
||||||
{user?.Policy?.IsAdministrator &&
|
</View>
|
||||||
!settings.hideRemoteSessionButton && (
|
)}
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
|
||||||
<AddToFavorites item={item} />
|
|
||||||
{settings.streamyStatsServerUrl &&
|
|
||||||
!settings.hideWatchlistsTab && (
|
|
||||||
<AddToWatchlist item={item} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
item,
|
|
||||||
navigation,
|
|
||||||
user,
|
|
||||||
itemWithSources,
|
|
||||||
settings.hideRemoteSessionButton,
|
|
||||||
settings.streamyStatsServerUrl,
|
|
||||||
settings.hideWatchlistsTab,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (item) {
|
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
|
||||||
setHeaderHeight(230);
|
|
||||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
|
||||||
else setHeaderHeight(350);
|
|
||||||
}
|
|
||||||
}, [item, orientation]);
|
|
||||||
|
|
||||||
if (!item || !selectedOptions) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex-1 relative'
|
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParallaxScrollView
|
|
||||||
className='flex-1'
|
|
||||||
headerHeight={headerHeight}
|
|
||||||
headerImage={
|
|
||||||
<View style={[{ flex: 1 }]}>
|
|
||||||
<ItemImage
|
|
||||||
variant={
|
|
||||||
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
|
||||||
}
|
|
||||||
item={item}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
) : (
|
||||||
logo={
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
logoUrl ? (
|
<Chromecast.Chromecast width={22} height={22} />
|
||||||
<Image
|
{item.Type !== "Program" && (
|
||||||
source={{
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
uri: logoUrl,
|
{!Platform.isTV && (
|
||||||
}}
|
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||||
style={{
|
)}
|
||||||
height: 130,
|
{user?.Policy?.IsAdministrator &&
|
||||||
width: "100%",
|
!settings.hideRemoteSessionButton && (
|
||||||
}}
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
contentFit='contain'
|
)}
|
||||||
onLoad={onLogoLoad}
|
|
||||||
onError={onLogoLoad}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
|
||||||
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
|
||||||
<ItemHeader item={item} className='mb-2' />
|
|
||||||
|
|
||||||
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<PlayButton
|
<AddToFavorites item={item} />
|
||||||
|
{settings.streamyStatsServerUrl &&
|
||||||
|
!settings.hideWatchlistsTab && (
|
||||||
|
<AddToWatchlist item={item} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
navigation,
|
||||||
|
user,
|
||||||
|
itemWithSources,
|
||||||
|
settings.hideRemoteSessionButton,
|
||||||
|
settings.streamyStatsServerUrl,
|
||||||
|
settings.hideWatchlistsTab,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
|
setHeaderHeight(230);
|
||||||
|
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||||
|
else setHeaderHeight(350);
|
||||||
|
}
|
||||||
|
}, [item, orientation]);
|
||||||
|
|
||||||
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 relative'
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParallaxScrollView
|
||||||
|
className='flex-1'
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
headerImage={
|
||||||
|
<View style={[{ flex: 1 }]}>
|
||||||
|
<ItemImage
|
||||||
|
variant={
|
||||||
|
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||||
|
}
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
logo={
|
||||||
|
logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
onLoad={onLogoLoad}
|
||||||
|
onError={onLogoLoad}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
|
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||||
|
<ItemHeader item={item} className='mb-2' />
|
||||||
|
|
||||||
|
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||||
|
<PlayButton
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
item={item}
|
||||||
|
colors={itemColors}
|
||||||
|
/>
|
||||||
|
<View className='w-1' />
|
||||||
|
{!isOffline && (
|
||||||
|
<MediaSourceButton
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
setSelectedOptions={setSelectedOptions}
|
||||||
|
item={itemWithSources}
|
||||||
colors={itemColors}
|
colors={itemColors}
|
||||||
/>
|
/>
|
||||||
<View className='w-1' />
|
)}
|
||||||
{!isOffline && (
|
|
||||||
<MediaSourceButton
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
setSelectedOptions={setSelectedOptions}
|
|
||||||
item={itemWithSources}
|
|
||||||
colors={itemColors}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
{item.Type === "Episode" && (
|
</View>
|
||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
{item.Type === "Episode" && (
|
||||||
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOffline &&
|
||||||
|
selectedOptions.mediaSource?.MediaStreams &&
|
||||||
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline &&
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
selectedOptions.mediaSource?.MediaStreams &&
|
|
||||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
{item.Type !== "Program" && (
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
<>
|
||||||
|
{item.Type === "Episode" && !isOffline && (
|
||||||
|
<CurrentSeries item={item} className='mb-2' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<ItemPeopleSections item={item} />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||||
<>
|
</>
|
||||||
{item.Type === "Episode" && !isOffline && (
|
)}
|
||||||
<CurrentSeries item={item} className='mb-2' />
|
</View>
|
||||||
)}
|
</ParallaxScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<ItemPeopleSections item={item} />
|
// Memoize the mobile component
|
||||||
|
const MemoizedItemContentMobile = React.memo(ItemContentMobile);
|
||||||
|
|
||||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
// Exported component that renders TV or mobile version based on platform
|
||||||
</>
|
export const ItemContent: React.FC<ItemContentProps> = (props) => {
|
||||||
)}
|
if (Platform.isTV && ItemContentTV) {
|
||||||
</View>
|
return <ItemContentTV {...props} />;
|
||||||
</ParallaxScrollView>
|
}
|
||||||
</View>
|
return <MemoizedItemContentMobile {...props} />;
|
||||||
);
|
};
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
873
components/ItemContent.tv.tsx
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
MediaStream,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
|
||||||
|
import {
|
||||||
|
TVBackdrop,
|
||||||
|
TVButton,
|
||||||
|
TVCastCrewText,
|
||||||
|
TVCastSection,
|
||||||
|
TVFavoriteButton,
|
||||||
|
TVMetadataBadges,
|
||||||
|
TVOptionButton,
|
||||||
|
TVProgressBar,
|
||||||
|
TVRefreshButton,
|
||||||
|
TVSeriesNavigation,
|
||||||
|
TVTechnicalDetails,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import type { Track } from "@/components/video-player/controls/types";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
export type SelectedOptions = {
|
||||||
|
bitrate: Bitrate;
|
||||||
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
|
audioIndex: number | undefined;
|
||||||
|
subtitleIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ItemContentTVProps {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
itemWithSources?: BaseItemDto | null;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
|
||||||
|
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||||
|
({ item, itemWithSources }) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const _itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
|
// State for first episode card ref (used for focus guide)
|
||||||
|
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
// Fetch season episodes for episodes
|
||||||
|
const { data: seasonEpisodes = [] } = useQuery({
|
||||||
|
queryKey: ["episodes", item?.SeasonId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return [];
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId,
|
||||||
|
userId: user.Id,
|
||||||
|
seasonId: item.SeasonId,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "Overview"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
!!api &&
|
||||||
|
!!user?.Id &&
|
||||||
|
!!item?.SeriesId &&
|
||||||
|
!!item?.SeasonId &&
|
||||||
|
item?.Type === "Episode",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
|
SelectedOptions | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
defaultAudioIndex,
|
||||||
|
defaultBitrate,
|
||||||
|
defaultMediaSource,
|
||||||
|
defaultSubtitleIndex,
|
||||||
|
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(
|
||||||
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
|
[api, item],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set default play options
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedOptions(() => ({
|
||||||
|
bitrate: defaultBitrate,
|
||||||
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
}));
|
||||||
|
}, [
|
||||||
|
defaultAudioIndex,
|
||||||
|
defaultBitrate,
|
||||||
|
defaultSubtitleIndex,
|
||||||
|
defaultMediaSource,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
if (!item || !selectedOptions) return;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
offline: isOffline ? "true" : "false",
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TV Option Modal hook for quality, audio, media source selectors
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|
||||||
|
// TV Subtitle Modal hook
|
||||||
|
const { showSubtitleModal } = useTVSubtitleModal();
|
||||||
|
|
||||||
|
// State for first actor card ref (used for focus guide)
|
||||||
|
const [_firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// State for last option button ref (used for upward focus guide from cast)
|
||||||
|
const [_lastOptionButtonRef, setLastOptionButtonRef] =
|
||||||
|
useState<View | null>(null);
|
||||||
|
|
||||||
|
// Get available audio tracks
|
||||||
|
const audioTracks = useMemo(() => {
|
||||||
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Audio",
|
||||||
|
);
|
||||||
|
return streams ?? [];
|
||||||
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
|
// Get available subtitle tracks (raw MediaStream[] for label lookup)
|
||||||
|
const subtitleStreams = useMemo(() => {
|
||||||
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
);
|
||||||
|
return streams ?? [];
|
||||||
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
|
// Store handleSubtitleChange in a ref for stable callback reference
|
||||||
|
const handleSubtitleChangeRef = useRef<((index: number) => void) | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||||
|
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||||
|
return subtitleStreams.map((stream) => ({
|
||||||
|
name:
|
||||||
|
stream.DisplayTitle ||
|
||||||
|
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||||
|
index: stream.Index ?? -1,
|
||||||
|
setTrack: () => {
|
||||||
|
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [subtitleStreams]);
|
||||||
|
|
||||||
|
// Get available media sources
|
||||||
|
const mediaSources = useMemo(() => {
|
||||||
|
return (itemWithSources ?? item)?.MediaSources ?? [];
|
||||||
|
}, [item, itemWithSources]);
|
||||||
|
|
||||||
|
// Audio options for selector
|
||||||
|
const audioOptions: TVOptionItem<number>[] = useMemo(() => {
|
||||||
|
return audioTracks.map((track) => ({
|
||||||
|
label:
|
||||||
|
track.DisplayTitle ||
|
||||||
|
`${track.Language || "Unknown"} (${track.Codec})`,
|
||||||
|
value: track.Index!,
|
||||||
|
selected: track.Index === selectedOptions?.audioIndex,
|
||||||
|
}));
|
||||||
|
}, [audioTracks, selectedOptions?.audioIndex]);
|
||||||
|
|
||||||
|
// Media source options for selector
|
||||||
|
const mediaSourceOptions: TVOptionItem<MediaSourceInfo>[] = useMemo(() => {
|
||||||
|
return mediaSources.map((source) => {
|
||||||
|
const videoStream = source.MediaStreams?.find(
|
||||||
|
(s) => s.Type === "Video",
|
||||||
|
);
|
||||||
|
const displayName =
|
||||||
|
videoStream?.DisplayTitle || source.Name || `Source ${source.Id}`;
|
||||||
|
return {
|
||||||
|
label: displayName,
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions?.mediaSource?.Id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [mediaSources, selectedOptions?.mediaSource?.Id]);
|
||||||
|
|
||||||
|
// Quality/bitrate options for selector
|
||||||
|
const qualityOptions: TVOptionItem<Bitrate>[] = useMemo(() => {
|
||||||
|
return BITRATES.map((bitrate) => ({
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selectedOptions?.bitrate?.value,
|
||||||
|
}));
|
||||||
|
}, [selectedOptions?.bitrate?.value]);
|
||||||
|
|
||||||
|
// Handlers for option changes
|
||||||
|
const handleAudioChange = useCallback((audioIndex: number) => {
|
||||||
|
setSelectedOptions((prev) =>
|
||||||
|
prev ? { ...prev, audioIndex } : undefined,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubtitleChange = useCallback((subtitleIndex: number) => {
|
||||||
|
setSelectedOptions((prev) =>
|
||||||
|
prev ? { ...prev, subtitleIndex } : undefined,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep the ref updated with the latest callback
|
||||||
|
handleSubtitleChangeRef.current = handleSubtitleChange;
|
||||||
|
|
||||||
|
const handleMediaSourceChange = useCallback(
|
||||||
|
(mediaSource: MediaSourceInfo) => {
|
||||||
|
const defaultAudio = mediaSource.MediaStreams?.find(
|
||||||
|
(s) => s.Type === "Audio" && s.IsDefault,
|
||||||
|
);
|
||||||
|
const defaultSubtitle = mediaSource.MediaStreams?.find(
|
||||||
|
(s) => s.Type === "Subtitle" && s.IsDefault,
|
||||||
|
);
|
||||||
|
setSelectedOptions((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
mediaSource,
|
||||||
|
audioIndex: defaultAudio?.Index ?? prev.audioIndex,
|
||||||
|
subtitleIndex: defaultSubtitle?.Index ?? -1,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleQualityChange = useCallback((bitrate: Bitrate) => {
|
||||||
|
setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle server-side subtitle download - invalidate queries to refresh tracks
|
||||||
|
const handleServerSubtitleDownloaded = useCallback(() => {
|
||||||
|
if (item?.Id) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
|
||||||
|
}
|
||||||
|
}, [item?.Id, queryClient]);
|
||||||
|
|
||||||
|
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
|
||||||
|
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
|
||||||
|
if (!api || !item?.Id) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch fresh item data with media sources
|
||||||
|
const response = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: item.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freshItem = response.data;
|
||||||
|
const mediaSourceId = selectedOptions?.mediaSource?.Id;
|
||||||
|
|
||||||
|
// Find the matching media source
|
||||||
|
const mediaSource = mediaSourceId
|
||||||
|
? freshItem.MediaSources?.find(
|
||||||
|
(s: MediaSourceInfo) => s.Id === mediaSourceId,
|
||||||
|
)
|
||||||
|
: freshItem.MediaSources?.[0];
|
||||||
|
|
||||||
|
// Get subtitle streams from the fresh data
|
||||||
|
const streams =
|
||||||
|
mediaSource?.MediaStreams?.filter(
|
||||||
|
(s: MediaStream) => s.Type === "Subtitle",
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
// Convert to Track[] with setTrack callbacks
|
||||||
|
return streams.map((stream) => ({
|
||||||
|
name:
|
||||||
|
stream.DisplayTitle ||
|
||||||
|
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||||
|
index: stream.Index ?? -1,
|
||||||
|
setTrack: () => {
|
||||||
|
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh subtitle tracks:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [api, item?.Id, selectedOptions?.mediaSource?.Id]);
|
||||||
|
|
||||||
|
// Get display values for buttons
|
||||||
|
const selectedAudioLabel = useMemo(() => {
|
||||||
|
const track = audioTracks.find(
|
||||||
|
(t) => t.Index === selectedOptions?.audioIndex,
|
||||||
|
);
|
||||||
|
return track?.DisplayTitle || track?.Language || t("item_card.audio");
|
||||||
|
}, [audioTracks, selectedOptions?.audioIndex, t]);
|
||||||
|
|
||||||
|
const selectedSubtitleLabel = useMemo(() => {
|
||||||
|
if (selectedOptions?.subtitleIndex === -1)
|
||||||
|
return t("item_card.subtitles.none");
|
||||||
|
const track = subtitleStreams.find(
|
||||||
|
(t) => t.Index === selectedOptions?.subtitleIndex,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
||||||
|
);
|
||||||
|
}, [subtitleStreams, selectedOptions?.subtitleIndex, t]);
|
||||||
|
|
||||||
|
const selectedMediaSourceLabel = useMemo(() => {
|
||||||
|
const source = selectedOptions?.mediaSource;
|
||||||
|
if (!source) return t("item_card.video");
|
||||||
|
const videoStream = source.MediaStreams?.find((s) => s.Type === "Video");
|
||||||
|
return videoStream?.DisplayTitle || source.Name || t("item_card.video");
|
||||||
|
}, [selectedOptions?.mediaSource, t]);
|
||||||
|
|
||||||
|
const selectedQualityLabel = useMemo(() => {
|
||||||
|
return selectedOptions?.bitrate?.key || t("item_card.quality");
|
||||||
|
}, [selectedOptions?.bitrate?.key, t]);
|
||||||
|
|
||||||
|
// Format year and duration
|
||||||
|
const year = item?.ProductionYear;
|
||||||
|
const duration = item?.RunTimeTicks
|
||||||
|
? runtimeTicksToMinutes(item.RunTimeTicks)
|
||||||
|
: null;
|
||||||
|
const hasProgress = (item?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||||
|
const remainingTime = hasProgress
|
||||||
|
? runtimeTicksToMinutes(
|
||||||
|
(item?.RunTimeTicks || 0) -
|
||||||
|
(item?.UserData?.PlaybackPositionTicks || 0),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Get director
|
||||||
|
const director = item?.People?.find((p) => p.Type === "Director");
|
||||||
|
|
||||||
|
// Get cast (first 3 for text display)
|
||||||
|
const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3);
|
||||||
|
|
||||||
|
// Get full cast for visual display (up to 10 actors)
|
||||||
|
const fullCast = useMemo(() => {
|
||||||
|
return (
|
||||||
|
item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? []
|
||||||
|
);
|
||||||
|
}, [item?.People]);
|
||||||
|
|
||||||
|
// Whether to show visual cast section
|
||||||
|
const showVisualCast =
|
||||||
|
(item?.Type === "Movie" ||
|
||||||
|
item?.Type === "Series" ||
|
||||||
|
item?.Type === "Episode") &&
|
||||||
|
fullCast.length > 0;
|
||||||
|
|
||||||
|
// Series/Season image URLs for episodes
|
||||||
|
const seriesImageUrl = useMemo(() => {
|
||||||
|
if (item?.Type !== "Episode" || !item.SeriesId) return null;
|
||||||
|
return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 });
|
||||||
|
}, [api, item?.Type, item?.SeriesId]);
|
||||||
|
|
||||||
|
const seasonImageUrl = useMemo(() => {
|
||||||
|
if (item?.Type !== "Episode") return null;
|
||||||
|
const seasonId = item.SeasonId || item.ParentId;
|
||||||
|
if (!seasonId) return null;
|
||||||
|
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
|
||||||
|
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
||||||
|
|
||||||
|
// Episode thumbnail URL - episode's own primary image (16:9 for episodes)
|
||||||
|
const episodeThumbnailUrl = useMemo(() => {
|
||||||
|
if (item?.Type !== "Episode" || !api) return null;
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
// Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled
|
||||||
|
const seriesThumbUrl = useMemo(() => {
|
||||||
|
if (item?.Type !== "Episode" || !item.SeriesId || !api) return null;
|
||||||
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
// Determine which option button is the last one (for focus guide targeting)
|
||||||
|
const lastOptionButton = useMemo(() => {
|
||||||
|
const hasSubtitleOption =
|
||||||
|
subtitleStreams.length > 0 ||
|
||||||
|
selectedOptions?.subtitleIndex !== undefined;
|
||||||
|
const hasAudioOption = audioTracks.length > 0;
|
||||||
|
const hasMediaSourceOption = mediaSources.length > 1;
|
||||||
|
|
||||||
|
if (hasSubtitleOption) return "subtitle";
|
||||||
|
if (hasAudioOption) return "audio";
|
||||||
|
if (hasMediaSourceOption) return "mediaSource";
|
||||||
|
return "quality";
|
||||||
|
}, [
|
||||||
|
subtitleStreams.length,
|
||||||
|
selectedOptions?.subtitleIndex,
|
||||||
|
audioTracks.length,
|
||||||
|
mediaSources.length,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
const handleActorPress = useCallback(
|
||||||
|
(personId: string) => {
|
||||||
|
router.push(`/(auth)/persons/${personId}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSeriesPress = useCallback(() => {
|
||||||
|
if (item?.SeriesId) {
|
||||||
|
router.push(`/(auth)/series/${item.SeriesId}`);
|
||||||
|
}
|
||||||
|
}, [router, item?.SeriesId]);
|
||||||
|
|
||||||
|
const handleSeasonPress = useCallback(() => {
|
||||||
|
if (item?.SeriesId && item?.ParentIndexNumber) {
|
||||||
|
router.push(
|
||||||
|
`/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
|
||||||
|
|
||||||
|
const handleEpisodePress = useCallback(
|
||||||
|
(episode: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(episode, "(home)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Full-screen backdrop */}
|
||||||
|
<TVBackdrop item={item} />
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 140,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Top section - Logo/Title + Metadata */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
minHeight: SCREEN_HEIGHT * 0.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Content */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Logo or Title */}
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logoUrl }}
|
||||||
|
style={{
|
||||||
|
height: 150,
|
||||||
|
width: "80%",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
contentPosition='left'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.display,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Episode info for TV shows */}
|
||||||
|
{item.Type === "Episode" && (
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.title,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "white",
|
||||||
|
marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
S{item.ParentIndexNumber} E{item.IndexNumber} · {item.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata badges row */}
|
||||||
|
<TVMetadataBadges
|
||||||
|
year={year}
|
||||||
|
duration={duration}
|
||||||
|
officialRating={item.OfficialRating}
|
||||||
|
communityRating={item.CommunityRating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{item.Genres && item.Genres.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<GenreTags genres={item.Genres} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
{item.Overview && (
|
||||||
|
<BlurView
|
||||||
|
intensity={10}
|
||||||
|
tint='light'
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#E5E7EB",
|
||||||
|
lineHeight: 32,
|
||||||
|
}}
|
||||||
|
numberOfLines={4}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVButton
|
||||||
|
onPress={handlePlay}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
variant='primary'
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='play'
|
||||||
|
size={28}
|
||||||
|
color='#000000'
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#000000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasProgress
|
||||||
|
? `${remainingTime} ${t("item_card.left")}`
|
||||||
|
: t("common.play")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
<TVFavoriteButton item={item} />
|
||||||
|
<TVRefreshButton itemId={item.Id} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Playback options */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Quality selector */}
|
||||||
|
<TVOptionButton
|
||||||
|
ref={
|
||||||
|
lastOptionButton === "quality"
|
||||||
|
? setLastOptionButtonRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={t("item_card.quality")}
|
||||||
|
value={selectedQualityLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("item_card.quality"),
|
||||||
|
options: qualityOptions,
|
||||||
|
onSelect: handleQualityChange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Media source selector (only if multiple sources) */}
|
||||||
|
{mediaSources.length > 1 && (
|
||||||
|
<TVOptionButton
|
||||||
|
ref={
|
||||||
|
lastOptionButton === "mediaSource"
|
||||||
|
? setLastOptionButtonRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={t("item_card.video")}
|
||||||
|
value={selectedMediaSourceLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: mediaSourceOptions,
|
||||||
|
onSelect: handleMediaSourceChange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio selector */}
|
||||||
|
{audioTracks.length > 0 && (
|
||||||
|
<TVOptionButton
|
||||||
|
ref={
|
||||||
|
lastOptionButton === "audio"
|
||||||
|
? setLastOptionButtonRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={t("item_card.audio")}
|
||||||
|
value={selectedAudioLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("item_card.audio"),
|
||||||
|
options: audioOptions,
|
||||||
|
onSelect: handleAudioChange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subtitle selector */}
|
||||||
|
{(subtitleStreams.length > 0 ||
|
||||||
|
selectedOptions?.subtitleIndex !== undefined) && (
|
||||||
|
<TVOptionButton
|
||||||
|
ref={
|
||||||
|
lastOptionButton === "subtitle"
|
||||||
|
? setLastOptionButtonRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={t("item_card.subtitles.label")}
|
||||||
|
value={selectedSubtitleLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showSubtitleModal({
|
||||||
|
item,
|
||||||
|
mediaSourceId: selectedOptions?.mediaSource?.Id,
|
||||||
|
subtitleTracks: subtitleTracksForModal,
|
||||||
|
currentSubtitleIndex:
|
||||||
|
selectedOptions?.subtitleIndex ?? -1,
|
||||||
|
onDisableSubtitles: () => handleSubtitleChange(-1),
|
||||||
|
onServerSubtitleDownloaded:
|
||||||
|
handleServerSubtitleDownloaded,
|
||||||
|
refreshSubtitleTracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress bar (if partially watched) */}
|
||||||
|
{hasProgress && item.RunTimeTicks != null && (
|
||||||
|
<TVProgressBar
|
||||||
|
progress={
|
||||||
|
(item.UserData?.PlaybackPositionTicks || 0) /
|
||||||
|
item.RunTimeTicks
|
||||||
|
}
|
||||||
|
fillColor='#FFFFFF'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Poster */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? SCREEN_WIDTH * 0.35
|
||||||
|
: SCREEN_WIDTH * 0.22,
|
||||||
|
marginLeft: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: item.Type === "Episode" ? 16 / 9 : 2 / 3,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.Type === "Episode" ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri:
|
||||||
|
settings.showSeriesPosterOnEpisode && seriesThumbUrl
|
||||||
|
? seriesThumbUrl
|
||||||
|
: episodeThumbnailUrl!,
|
||||||
|
}}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ItemImage
|
||||||
|
variant='Primary'
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Additional info section */}
|
||||||
|
<View style={{ marginTop: 40 }}>
|
||||||
|
{/* Season Episodes - Episode only */}
|
||||||
|
{item.Type === "Episode" && seasonEpisodes.length > 1 && (
|
||||||
|
<View style={{ marginBottom: 40 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.more_from_this_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ marginHorizontal: -80, overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 80,
|
||||||
|
paddingVertical: 12,
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seasonEpisodes.map((episode, index) => (
|
||||||
|
<TVEpisodeCard
|
||||||
|
key={episode.Id}
|
||||||
|
episode={episode}
|
||||||
|
onPress={() => handleEpisodePress(episode)}
|
||||||
|
disabled={episode.Id === item.Id}
|
||||||
|
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* From this Series - Episode only */}
|
||||||
|
<TVSeriesNavigation
|
||||||
|
item={item}
|
||||||
|
seriesImageUrl={seriesImageUrl}
|
||||||
|
seasonImageUrl={seasonImageUrl}
|
||||||
|
onSeriesPress={handleSeriesPress}
|
||||||
|
onSeasonPress={handleSeasonPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
||||||
|
{showVisualCast && (
|
||||||
|
<TVCastSection
|
||||||
|
cast={fullCast}
|
||||||
|
apiBasePath={api?.basePath}
|
||||||
|
onActorPress={handleActorPress}
|
||||||
|
firstActorRefSetter={setFirstActorCardRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cast & Crew (text version - director, etc.) */}
|
||||||
|
<TVCastCrewText
|
||||||
|
director={director}
|
||||||
|
cast={cast}
|
||||||
|
hideCast={showVisualCast}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Technical details */}
|
||||||
|
{selectedOptions.mediaSource?.MediaStreams &&
|
||||||
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<TVTechnicalDetails
|
||||||
|
mediaStreams={selectedOptions.mediaSource.MediaStreams}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files)
|
||||||
|
export const ItemContent = ItemContentTV;
|
||||||
163
components/ItemContentSkeleton.tv.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Dimensions, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
|
||||||
|
export const ItemContentSkeletonTV: React.FC = () => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingTop: insets.top + 140,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Content placeholders */}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Logo placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 150,
|
||||||
|
width: "80%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Metadata badges row */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
width: 60,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
width: 80,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
width: 50,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Genres placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
width: 80,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
width: 100,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
width: 70,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Overview placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 18,
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 18,
|
||||||
|
width: "90%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 18,
|
||||||
|
width: "75%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Play button placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
width: 180,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Poster placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: SCREEN_WIDTH * 0.22,
|
||||||
|
marginLeft: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -77,7 +77,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
|||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
{t("item_card.subtitles")}
|
{t("item_card.subtitles.label")}
|
||||||
</Text>
|
</Text>
|
||||||
<SubtitleStreamInfo
|
<SubtitleStreamInfo
|
||||||
subtitleStreams={
|
subtitleStreams={
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("item_card.subtitles"),
|
title: t("item_card.subtitles.label"),
|
||||||
options: [noneOption, ...subtitleOptions],
|
options: [noneOption, ...subtitleOptions],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const trigger = (
|
const trigger = (
|
||||||
<View className='flex flex-col' {...props}>
|
<View className='flex flex-col' {...props}>
|
||||||
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
||||||
{t("item_card.subtitles")}
|
{t("item_card.subtitles.label")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
@@ -97,7 +97,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
title={t("item_card.subtitles")}
|
title={t("item_card.subtitles.label")}
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
onOptionSelect={handleOptionSelect}
|
onOptionSelect={handleOptionSelect}
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
|
||||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// TV: Show white checkmark when watched
|
||||||
|
if (
|
||||||
|
item.UserData?.Played &&
|
||||||
|
(item.Type === "Movie" || item.Type === "Episode")
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.9)",
|
||||||
|
borderRadius: 14,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='checkmark' size={18} color='black' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: Show purple triangle for unwatched
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{item.UserData?.Played === false &&
|
{item.UserData?.Played === false &&
|
||||||
|
|||||||
@@ -1,909 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import Animated, {
|
|
||||||
Easing,
|
|
||||||
interpolate,
|
|
||||||
runOnJS,
|
|
||||||
type SharedValue,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { ItemImage } from "../common/ItemImage";
|
|
||||||
import { getItemNavigation } from "../common/TouchableItemRouter";
|
|
||||||
import type { SelectedOptions } from "../ItemContent";
|
|
||||||
import { PlayButton } from "../PlayButton";
|
|
||||||
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
|
||||||
|
|
||||||
interface AppleTVCarouselProps {
|
|
||||||
initialIndex?: number;
|
|
||||||
onItemChange?: (index: number) => void;
|
|
||||||
scrollOffset?: SharedValue<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout Constants
|
|
||||||
const GRADIENT_HEIGHT_TOP = 150;
|
|
||||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
|
||||||
const LOGO_HEIGHT = 80;
|
|
||||||
|
|
||||||
// Position Constants
|
|
||||||
const LOGO_BOTTOM_POSITION = 260;
|
|
||||||
const GENRES_BOTTOM_POSITION = 220;
|
|
||||||
const OVERVIEW_BOTTOM_POSITION = 165;
|
|
||||||
const CONTROLS_BOTTOM_POSITION = 80;
|
|
||||||
const DOTS_BOTTOM_POSITION = 40;
|
|
||||||
|
|
||||||
// Size Constants
|
|
||||||
const DOT_HEIGHT = 6;
|
|
||||||
const DOT_ACTIVE_WIDTH = 20;
|
|
||||||
const DOT_INACTIVE_WIDTH = 12;
|
|
||||||
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
|
||||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
|
||||||
const TEXT_SKELETON_HEIGHT = 20;
|
|
||||||
const TEXT_SKELETON_WIDTH = 250;
|
|
||||||
const OVERVIEW_SKELETON_HEIGHT = 16;
|
|
||||||
const OVERVIEW_SKELETON_WIDTH = 400;
|
|
||||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
|
||||||
|
|
||||||
// Spacing Constants
|
|
||||||
const HORIZONTAL_PADDING = 40;
|
|
||||||
const DOT_PADDING = 2;
|
|
||||||
const DOT_GAP = 4;
|
|
||||||
const CONTROLS_GAP = 10;
|
|
||||||
const _TEXT_MARGIN_TOP = 16;
|
|
||||||
|
|
||||||
// Border Radius Constants
|
|
||||||
const DOT_BORDER_RADIUS = 3;
|
|
||||||
const LOGO_SKELETON_BORDER_RADIUS = 8;
|
|
||||||
const TEXT_SKELETON_BORDER_RADIUS = 4;
|
|
||||||
const PLAY_BUTTON_BORDER_RADIUS = 25;
|
|
||||||
const PLAYED_STATUS_BORDER_RADIUS = 20;
|
|
||||||
|
|
||||||
// Animation Constants
|
|
||||||
const DOT_ANIMATION_DURATION = 300;
|
|
||||||
const CAROUSEL_TRANSITION_DURATION = 250;
|
|
||||||
const PAN_ACTIVE_OFFSET = 10;
|
|
||||||
const TRANSLATION_THRESHOLD = 0.2;
|
|
||||||
const VELOCITY_THRESHOLD = 400;
|
|
||||||
|
|
||||||
// Text Constants
|
|
||||||
const GENRES_FONT_SIZE = 16;
|
|
||||||
const OVERVIEW_FONT_SIZE = 14;
|
|
||||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
|
||||||
const TEXT_SHADOW_RADIUS = 2;
|
|
||||||
const MAX_GENRES_COUNT = 2;
|
|
||||||
const MAX_BUTTON_WIDTH = 300;
|
|
||||||
const OVERVIEW_MAX_LINES = 2;
|
|
||||||
const OVERVIEW_MAX_WIDTH = "80%";
|
|
||||||
|
|
||||||
// Opacity Constants
|
|
||||||
const OVERLAY_OPACITY = 0.3;
|
|
||||||
const DOT_INACTIVE_OPACITY = 0.6;
|
|
||||||
const TEXT_OPACITY = 0.9;
|
|
||||||
|
|
||||||
// Color Constants
|
|
||||||
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
|
|
||||||
const SKELETON_ELEMENT_COLOR = "#333";
|
|
||||||
const SKELETON_ACTIVE_DOT_COLOR = "#666";
|
|
||||||
const _EMPTY_STATE_COLOR = "#666";
|
|
||||||
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
|
|
||||||
const LOGO_WIDTH_PERCENTAGE = "80%";
|
|
||||||
|
|
||||||
const DotIndicator = ({
|
|
||||||
index,
|
|
||||||
currentIndex,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
index: number;
|
|
||||||
currentIndex: number;
|
|
||||||
onPress: (index: number) => void;
|
|
||||||
}) => {
|
|
||||||
const isActive = index === currentIndex;
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
|
|
||||||
duration: DOT_ANIMATION_DURATION,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
}),
|
|
||||||
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
|
|
||||||
duration: DOT_ANIMATION_DURATION,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => onPress(index)}
|
|
||||||
style={{
|
|
||||||
padding: DOT_PADDING, // Increase touch area
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: DOT_HEIGHT,
|
|
||||||
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
|
|
||||||
borderRadius: DOT_BORDER_RADIUS,
|
|
||||||
},
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|
||||||
initialIndex = 0,
|
|
||||||
onItemChange,
|
|
||||||
scrollOffset,
|
|
||||||
}) => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { isConnected, serverConnected } = useNetworkStatus();
|
|
||||||
const router = useRouter();
|
|
||||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
|
||||||
const isLandscape = screenWidth >= screenHeight;
|
|
||||||
const carouselHeight = useMemo(
|
|
||||||
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
|
|
||||||
[isLandscape, screenHeight],
|
|
||||||
);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
||||||
const translateX = useSharedValue(-initialIndex * screenWidth);
|
|
||||||
|
|
||||||
const isQueryEnabled =
|
|
||||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
|
||||||
|
|
||||||
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres", "Overview"],
|
|
||||||
limit: 2,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
|
|
||||||
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres", "Overview"],
|
|
||||||
limit: 2,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
|
|
||||||
{
|
|
||||||
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user.Id,
|
|
||||||
limit: 2,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
});
|
|
||||||
return response.data || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
const continueItems = continueWatchingData ?? [];
|
|
||||||
const nextItems = nextUpData ?? [];
|
|
||||||
const recentItems = recentlyAddedData ?? [];
|
|
||||||
|
|
||||||
const allItems = [
|
|
||||||
...continueItems.slice(0, 2),
|
|
||||||
...nextItems.slice(0, 2),
|
|
||||||
...recentItems.slice(0, 2),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Deduplicate by item ID to prevent duplicate keys
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return allItems.filter((item) => {
|
|
||||||
if (item.Id && !seen.has(item.Id)) {
|
|
||||||
seen.add(item.Id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
|
|
||||||
const hasItems = items.length > 0;
|
|
||||||
|
|
||||||
// Only get play settings if we have valid items
|
|
||||||
const currentItem = hasItems ? items[currentIndex] : null;
|
|
||||||
|
|
||||||
// Extract colors for the current item only (for performance)
|
|
||||||
const currentItemColors = useImageColorsReturn({ item: currentItem });
|
|
||||||
|
|
||||||
// Create a fallback empty item for useDefaultPlaySettings when no item is available
|
|
||||||
const itemForPlaySettings = currentItem || { MediaSources: [] };
|
|
||||||
const {
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultMediaSource,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
|
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
|
||||||
SelectedOptions | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only set options if we have valid current item
|
|
||||||
if (currentItem) {
|
|
||||||
setSelectedOptions({
|
|
||||||
bitrate: defaultBitrate,
|
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSelectedOptions(undefined);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
defaultMediaSource,
|
|
||||||
currentIndex,
|
|
||||||
currentItem,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasItems) {
|
|
||||||
setCurrentIndex(initialIndex);
|
|
||||||
translateX.value = -initialIndex * screenWidth;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentIndex((prev) => {
|
|
||||||
const newIndex = Math.min(prev, items.length - 1);
|
|
||||||
translateX.value = -newIndex * screenWidth;
|
|
||||||
return newIndex;
|
|
||||||
});
|
|
||||||
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
translateX.value = -currentIndex * screenWidth;
|
|
||||||
}, [currentIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasItems) {
|
|
||||||
onItemChange?.(currentIndex);
|
|
||||||
}
|
|
||||||
}, [hasItems, currentIndex, onItemChange]);
|
|
||||||
|
|
||||||
const goToIndex = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (!hasItems || index < 0 || index >= items.length) return;
|
|
||||||
|
|
||||||
translateX.value = withTiming(-index * screenWidth, {
|
|
||||||
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
|
|
||||||
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
|
|
||||||
});
|
|
||||||
|
|
||||||
setCurrentIndex(index);
|
|
||||||
onItemChange?.(index);
|
|
||||||
},
|
|
||||||
[hasItems, items, onItemChange, screenWidth, translateX],
|
|
||||||
);
|
|
||||||
|
|
||||||
const navigateToItem = useCallback(
|
|
||||||
(item: BaseItemDto) => {
|
|
||||||
const navigation = getItemNavigation(item, "(home)");
|
|
||||||
router.push(navigation as any);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const panGesture = Gesture.Pan()
|
|
||||||
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
|
|
||||||
.onUpdate((event) => {
|
|
||||||
translateX.value = -currentIndex * screenWidth + event.translationX;
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
const velocity = event.velocityX;
|
|
||||||
const translation = event.translationX;
|
|
||||||
|
|
||||||
let newIndex = currentIndex;
|
|
||||||
|
|
||||||
// Improved thresholds for more responsive navigation
|
|
||||||
if (
|
|
||||||
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
|
|
||||||
Math.abs(velocity) > VELOCITY_THRESHOLD
|
|
||||||
) {
|
|
||||||
if (translation > 0 && currentIndex > 0) {
|
|
||||||
newIndex = currentIndex - 1;
|
|
||||||
} else if (
|
|
||||||
translation < 0 &&
|
|
||||||
items &&
|
|
||||||
currentIndex < items.length - 1
|
|
||||||
) {
|
|
||||||
newIndex = currentIndex + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runOnJS(goToIndex)(newIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
const containerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [{ translateX: translateX.value }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
|
||||||
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
if (!scrollOffset) return {};
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[-carouselHeight / 2, 0, carouselHeight * 0.75],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[2, 1, 1],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderDots = () => {
|
|
||||||
if (!hasItems || items.length <= 1) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: DOTS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: DOT_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map((_, index) => (
|
|
||||||
<DotIndicator
|
|
||||||
key={index}
|
|
||||||
index={index}
|
|
||||||
currentIndex={currentIndex}
|
|
||||||
onPress={goToIndex}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSkeletonLoader = () => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: screenWidth,
|
|
||||||
height: carouselHeight,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: SKELETON_BACKGROUND_COLOR,
|
|
||||||
position: "absolute",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dark Overlay Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black Top Skeleton */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: GRADIENT_HEIGHT_TOP,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black Bottom Skeleton */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: GRADIENT_HEIGHT_BOTTOM,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logo Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: LOGO_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: LOGO_HEIGHT,
|
|
||||||
width: LOGO_WIDTH_PERCENTAGE,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Type and Genres Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: GENRES_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: TEXT_SKELETON_HEIGHT,
|
|
||||||
width: TEXT_SKELETON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Overview Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Controls Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: CONTROLS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: CONTROLS_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Play Button Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: PLAY_BUTTON_SKELETON_HEIGHT,
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: MAX_BUTTON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Played Status Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: PLAYED_STATUS_SKELETON_SIZE,
|
|
||||||
height: PLAYED_STATUS_SKELETON_SIZE,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Dots Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: DOTS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: DOT_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[1, 2, 3].map((_, index) => (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
|
|
||||||
height: DOT_HEIGHT,
|
|
||||||
backgroundColor:
|
|
||||||
index === 0
|
|
||||||
? SKELETON_ACTIVE_DOT_COLOR
|
|
||||||
: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: DOT_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderItem = (item: BaseItemDto, _index: number) => {
|
|
||||||
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={item.Id}
|
|
||||||
style={{
|
|
||||||
width: screenWidth,
|
|
||||||
height: carouselHeight,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background Backdrop */}
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "absolute",
|
|
||||||
},
|
|
||||||
headerAnimatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ItemImage
|
|
||||||
item={item}
|
|
||||||
variant='Backdrop'
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Dark Overlay */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black at Top */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: GRADIENT_HEIGHT_TOP,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black at Bottom */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: GRADIENT_HEIGHT_BOTTOM,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logo Section */}
|
|
||||||
{itemLogoUrl && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => navigateToItem(item)}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: LOGO_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: itemLogoUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: LOGO_HEIGHT,
|
|
||||||
width: LOGO_WIDTH_PERCENTAGE,
|
|
||||||
}}
|
|
||||||
contentFit='contain'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Type and Genres Section */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: GENRES_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
||||||
<Animated.Text
|
|
||||||
style={{
|
|
||||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
|
|
||||||
fontSize: GENRES_FONT_SIZE,
|
|
||||||
fontWeight: "500",
|
|
||||||
textAlign: "center",
|
|
||||||
textShadowColor: TEXT_SHADOW_COLOR,
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
let typeLabel = "";
|
|
||||||
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
// For episodes, show season and episode number
|
|
||||||
const season = item.ParentIndexNumber;
|
|
||||||
const episode = item.IndexNumber;
|
|
||||||
if (season && episode) {
|
|
||||||
typeLabel = `S${season} • E${episode}`;
|
|
||||||
} else {
|
|
||||||
typeLabel = "Episode";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
typeLabel =
|
|
||||||
item.Type === "Series"
|
|
||||||
? "TV Show"
|
|
||||||
: item.Type === "Movie"
|
|
||||||
? "Movie"
|
|
||||||
: item.Type || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const genres =
|
|
||||||
item.Genres && item.Genres.length > 0
|
|
||||||
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (typeLabel && genres) {
|
|
||||||
return `${typeLabel} • ${genres}`;
|
|
||||||
} else if (typeLabel) {
|
|
||||||
return typeLabel;
|
|
||||||
} else if (genres) {
|
|
||||||
return genres;
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</Animated.Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Overview Section - for Episodes and Movies */}
|
|
||||||
{(item.Type === "Episode" || item.Type === "Movie") &&
|
|
||||||
item.Overview && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
||||||
<Animated.Text
|
|
||||||
numberOfLines={OVERVIEW_MAX_LINES}
|
|
||||||
style={{
|
|
||||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
|
||||||
fontSize: OVERVIEW_FONT_SIZE,
|
|
||||||
fontWeight: "400",
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: OVERVIEW_MAX_WIDTH,
|
|
||||||
textShadowColor: TEXT_SHADOW_COLOR,
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.Overview}
|
|
||||||
</Animated.Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: CONTROLS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: CONTROLS_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Play Button */}
|
|
||||||
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
|
|
||||||
{selectedOptions && (
|
|
||||||
<PlayButton
|
|
||||||
item={item}
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
colors={currentItemColors}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Mark as Played */}
|
|
||||||
<MarkAsPlayedLargeButton
|
|
||||||
isPlayed={item.UserData?.Played ?? false}
|
|
||||||
onToggle={togglePlayedStatus}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle loading state
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: carouselHeight,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderSkeletonLoader()}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty items
|
|
||||||
if (!hasItems) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
|
||||||
backgroundColor: "#000",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GestureDetector gesture={panGesture}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
|
||||||
flexDirection: "row",
|
|
||||||
width: screenWidth * items.length,
|
|
||||||
},
|
|
||||||
containerAnimatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => renderItem(item, index))}
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
|
|
||||||
{/* Animated Dots Indicator */}
|
|
||||||
{renderDots()}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Button, Host } from "@expo/ui/swift-ui";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Platform, View } from "react-native";
|
|
||||||
import { RoundButton } from "../RoundButton";
|
|
||||||
|
|
||||||
interface MarkAsPlayedLargeButtonProps {
|
|
||||||
isPlayed: boolean;
|
|
||||||
onToggle: (isPlayed: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MarkAsPlayedLargeButton: React.FC<
|
|
||||||
MarkAsPlayedLargeButtonProps
|
|
||||||
> = ({ isPlayed, onToggle }) => {
|
|
||||||
if (Platform.OS === "ios")
|
|
||||||
return (
|
|
||||||
<Host
|
|
||||||
style={{
|
|
||||||
flex: 0,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
flexDirection: "row",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
|
|
||||||
<View>
|
|
||||||
<Ionicons
|
|
||||||
name='checkmark'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
style={{
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Button>
|
|
||||||
</Host>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<RoundButton
|
|
||||||
size='large'
|
|
||||||
icon={isPlayed ? "checkmark" : "checkmark"}
|
|
||||||
onPress={() => onToggle(isPlayed)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,50 +1,132 @@
|
|||||||
import React, { useState } from "react";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
Platform,
|
Platform,
|
||||||
|
Pressable,
|
||||||
TextInput,
|
TextInput,
|
||||||
type TextInputProps,
|
type TextInputProps,
|
||||||
TouchableOpacity,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
interface InputProps extends TextInputProps {
|
interface InputProps extends TextInputProps {
|
||||||
extraClassName?: string; // new prop for additional classes
|
extraClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Input(props: InputProps) {
|
export function Input(props: InputProps) {
|
||||||
const { style, extraClassName = "", ...otherProps } = props;
|
const { style, extraClassName = "", ...otherProps } = props;
|
||||||
const inputRef = React.useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
return Platform.isTV ? (
|
const animateFocus = (focused: boolean) => {
|
||||||
<TouchableOpacity
|
Animated.timing(scale, {
|
||||||
onPress={() => inputRef?.current?.focus?.()}
|
toValue: focused ? 1.02 : 1,
|
||||||
activeOpacity={1}
|
duration: 150,
|
||||||
>
|
easing: Easing.out(Easing.quad),
|
||||||
<TextInput
|
useNativeDriver: true,
|
||||||
ref={inputRef}
|
}).start();
|
||||||
className={`
|
};
|
||||||
w-full text-lg px-5 py-4 rounded-2xl
|
|
||||||
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-transparent"}
|
const handleFocus = () => {
|
||||||
text-white ${extraClassName}
|
setIsFocused(true);
|
||||||
`}
|
animateFocus(true);
|
||||||
allowFontScaling={false}
|
};
|
||||||
style={[
|
|
||||||
style,
|
const handleBlur = () => {
|
||||||
{
|
setIsFocused(false);
|
||||||
backgroundColor: isFocused ? "#ffffff88" : "#8f8d8d88",
|
animateFocus(false);
|
||||||
},
|
};
|
||||||
]}
|
|
||||||
placeholderTextColor={"#ffffffff"}
|
if (Platform.isTV) {
|
||||||
clearButtonMode='while-editing'
|
const containerStyle = {
|
||||||
onFocus={() => setIsFocused(true)}
|
height: 48,
|
||||||
onBlur={() => setIsFocused(false)}
|
borderRadius: 50,
|
||||||
{...otherProps}
|
borderWidth: isFocused ? 1.5 : 1,
|
||||||
/>
|
borderColor: isFocused
|
||||||
</TouchableOpacity>
|
? "rgba(255, 255, 255, 0.3)"
|
||||||
) : (
|
: "rgba(255, 255, 255, 0.1)",
|
||||||
|
overflow: "hidden" as const,
|
||||||
|
flexDirection: "row" as const,
|
||||||
|
alignItems: "center" as const,
|
||||||
|
paddingLeft: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputElement = (
|
||||||
|
<>
|
||||||
|
<Ionicons
|
||||||
|
name='search'
|
||||||
|
size={20}
|
||||||
|
color={isFocused ? "#999" : "#666"}
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
allowFontScaling={false}
|
||||||
|
placeholderTextColor='#666'
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
height: 48,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "400",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => inputRef.current?.focus()}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<BlurView
|
||||||
|
intensity={isFocused ? 90 : 80}
|
||||||
|
tint='dark'
|
||||||
|
style={containerStyle}
|
||||||
|
>
|
||||||
|
{inputElement}
|
||||||
|
</BlurView>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
containerStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: isFocused
|
||||||
|
? "rgba(255, 255, 255, 0.12)"
|
||||||
|
: "rgba(255, 255, 255, 0.08)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{inputElement}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile version unchanged
|
||||||
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className='p-4 rounded-xl bg-neutral-900'
|
className={`p-4 rounded-xl bg-neutral-900 ${extraClassName}`}
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
placeholderTextColor={"#9CA3AF"}
|
placeholderTextColor={"#9CA3AF"}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
|
||||||
interface ProgressBarProps {
|
interface ProgressBarProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -39,8 +39,9 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: `${progress}%`,
|
width: `${progress}%`,
|
||||||
|
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
|
||||||
}}
|
}}
|
||||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
233
components/home/Favorites.tv.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import heart from "@/assets/icons/heart.fill.png";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
|
const TOP_PADDING = 100;
|
||||||
|
const SECTION_GAP = 10;
|
||||||
|
|
||||||
|
type FavoriteTypes =
|
||||||
|
| "Series"
|
||||||
|
| "Movie"
|
||||||
|
| "Episode"
|
||||||
|
| "Video"
|
||||||
|
| "BoxSet"
|
||||||
|
| "Playlist";
|
||||||
|
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||||
|
|
||||||
|
export const Favorites = () => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const pageSize = 20;
|
||||||
|
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||||
|
Series: false,
|
||||||
|
Movie: false,
|
||||||
|
Episode: false,
|
||||||
|
Video: false,
|
||||||
|
BoxSet: false,
|
||||||
|
Playlist: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchFavoritesByType = useCallback(
|
||||||
|
async (
|
||||||
|
itemType: BaseItemKind,
|
||||||
|
startIndex: number = 0,
|
||||||
|
limit: number = 20,
|
||||||
|
) => {
|
||||||
|
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: false,
|
||||||
|
startIndex: startIndex,
|
||||||
|
limit: limit,
|
||||||
|
includeItemTypes: [itemType],
|
||||||
|
});
|
||||||
|
const items = response.data.Items || [];
|
||||||
|
|
||||||
|
if (startIndex === 0) {
|
||||||
|
setEmptyState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[itemType as FavoriteTypes]: items.length === 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
[api, user],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEmptyState({
|
||||||
|
Series: false,
|
||||||
|
Movie: false,
|
||||||
|
Episode: false,
|
||||||
|
Video: false,
|
||||||
|
BoxSet: false,
|
||||||
|
Playlist: false,
|
||||||
|
});
|
||||||
|
}, [api, user]);
|
||||||
|
|
||||||
|
const areAllEmpty = () => {
|
||||||
|
const loadedCategories = Object.values(emptyState);
|
||||||
|
return (
|
||||||
|
loadedCategories.length > 0 &&
|
||||||
|
loadedCategories.every((isEmpty) => isEmpty)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFavoriteSeries = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Series", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoriteMovies = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Movie", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoriteEpisodes = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Episode", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoriteVideos = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Video", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoriteBoxsets = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("BoxSet", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoritePlaylists = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Playlist", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (areAllEmpty()) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
marginBottom: 16,
|
||||||
|
tintColor: Colors.primary,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
source={heart}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("favorites.noDataTitle")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("favorites.noData")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteSeries}
|
||||||
|
queryKey={["home", "favorites", "series"]}
|
||||||
|
title={t("favorites.series")}
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
isFirstSection
|
||||||
|
/>
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteMovies}
|
||||||
|
queryKey={["home", "favorites", "movies"]}
|
||||||
|
title={t("favorites.movies")}
|
||||||
|
hideIfEmpty
|
||||||
|
orientation='vertical'
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteEpisodes}
|
||||||
|
queryKey={["home", "favorites", "episodes"]}
|
||||||
|
title={t("favorites.episodes")}
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteVideos}
|
||||||
|
queryKey={["home", "favorites", "videos"]}
|
||||||
|
title={t("favorites.videos")}
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteBoxsets}
|
||||||
|
queryKey={["home", "favorites", "boxsets"]}
|
||||||
|
title={t("favorites.boxsets")}
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
queryFn={fetchFavoritePlaylists}
|
||||||
|
queryKey={["home", "favorites", "playlists"]}
|
||||||
|
title={t("favorites.playlists")}
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -44,6 +44,9 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
// Conditionally load TV version
|
||||||
|
const HomeTV = Platform.isTV ? require("./Home.tv").Home : null;
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "InfiniteScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -64,7 +67,7 @@ type MediaListSectionType = {
|
|||||||
|
|
||||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||||
|
|
||||||
export const Home = () => {
|
const HomeMobile = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -595,11 +598,14 @@ export const Home = () => {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
// Render Streamystats sections after Recently Added sections
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
// For custom sections: place at the very end
|
||||||
? 0
|
const hasSuggestedMovies =
|
||||||
: 1;
|
!settings?.streamyStatsMovieRecommendations &&
|
||||||
|
!settings?.home?.sections;
|
||||||
|
const streamystatsIndex =
|
||||||
|
sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||||
const hasStreamystatsContent =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
@@ -687,3 +693,11 @@ export const Home = () => {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Exported component that renders TV or mobile version based on platform
|
||||||
|
export const Home = () => {
|
||||||
|
if (Platform.isTV && HomeTV) {
|
||||||
|
return <HomeTV />;
|
||||||
|
}
|
||||||
|
return <HomeMobile />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -12,37 +12,38 @@ import {
|
|||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigation, useSegments } from "expo-router";
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Animated,
|
||||||
TouchableOpacity,
|
Easing,
|
||||||
|
ScrollView,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import Animated, {
|
|
||||||
useAnimatedRef,
|
|
||||||
useScrollViewOffset,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
|
||||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
|
||||||
|
import { TVHeroCarousel } from "@/components/home/TVHeroCarousel";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
|
const TOP_PADDING = 100;
|
||||||
|
// Generous gap between sections for Apple TV+ aesthetic
|
||||||
|
const SECTION_GAP = 24;
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "InfiniteScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
@@ -51,97 +52,137 @@ type InfiniteScrollingCollectionListSection = {
|
|||||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
priority?: 1 | 2;
|
||||||
|
parentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSectionType = {
|
type Section = InfiniteScrollingCollectionListSection;
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
|
||||||
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
export const HomeWithCarousel = () => {
|
export const Home = () => {
|
||||||
const router = useRouter();
|
const typography = useScaledTVTypography();
|
||||||
|
const _router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [_loading, setLoading] = useState(false);
|
const { settings } = useSettings();
|
||||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
||||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
|
||||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
serverConnected,
|
serverConnected,
|
||||||
loading: retryLoading,
|
loading: retryLoading,
|
||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
// Dynamic backdrop state with debounce
|
||||||
if (isConnected && !prevIsConnected.current) {
|
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||||
invalidateCache();
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Handle item focus with debounce
|
||||||
|
const handleItemFocus = useCallback((item: BaseItemDto) => {
|
||||||
|
// Clear any pending debounce timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
}
|
}
|
||||||
prevIsConnected.current = isConnected;
|
// Set new timer to update focused item after debounce delay
|
||||||
}, [isConnected, invalidateCache]);
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setFocusedItem(item);
|
||||||
const hasDownloads = useMemo(() => {
|
}, BACKDROP_DEBOUNCE_MS);
|
||||||
if (Platform.isTV) return false;
|
|
||||||
return downloadedItems.length > 0;
|
|
||||||
}, [downloadedItems]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className='ml-1.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name='download'
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [navigation, router, hasDownloads]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cleanCacheDirectory().catch((_e) =>
|
|
||||||
console.error("Something went wrong cleaning cache directory"),
|
|
||||||
);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const segments = useSegments();
|
// Cleanup debounce timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
return () => {
|
||||||
if ((segments as string[])[2] === "(home)")
|
if (debounceTimerRef.current) {
|
||||||
animatedScrollRef.current?.scrollTo({
|
clearTimeout(debounceTimerRef.current);
|
||||||
y: Platform.isTV ? -152 : -100,
|
}
|
||||||
animated: true,
|
};
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
|
// Get backdrop URL from focused item (only if setting is enabled)
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
if (!settings.showHomeBackdrop || !focusedItem) return null;
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: focusedItem,
|
||||||
|
quality: 90,
|
||||||
|
width: 1920,
|
||||||
});
|
});
|
||||||
|
}, [api, focusedItem, settings.showHomeBackdrop]);
|
||||||
|
|
||||||
|
// Crossfade animation for backdrop transitions
|
||||||
|
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
|
||||||
|
const [layer0Url, setLayer0Url] = useState<string | null>(null);
|
||||||
|
const [layer1Url, setLayer1Url] = useState<string | null>(null);
|
||||||
|
const layer0Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const layer1Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backdropUrl) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const performCrossfade = async () => {
|
||||||
|
// Prefetch the image before starting the crossfade
|
||||||
|
try {
|
||||||
|
await Image.prefetch(backdropUrl);
|
||||||
|
} catch {
|
||||||
|
// Continue even if prefetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
// Determine which layer to fade in
|
||||||
|
const incomingLayer = activeLayer === 0 ? 1 : 0;
|
||||||
|
const incomingOpacity =
|
||||||
|
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
|
||||||
|
const outgoingOpacity =
|
||||||
|
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
|
||||||
|
|
||||||
|
// Set the new URL on the incoming layer
|
||||||
|
if (incomingLayer === 0) {
|
||||||
|
setLayer0Url(backdropUrl);
|
||||||
|
} else {
|
||||||
|
setLayer1Url(backdropUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure image component has the new URL
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
// Crossfade: fade in the incoming layer, fade out the outgoing
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(incomingOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(outgoingOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setActiveLayer(incomingLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
performCrossfade();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
isCancelled = true;
|
||||||
};
|
};
|
||||||
}, [segments]);
|
}, [backdropUrl]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -162,6 +203,58 @@ export const HomeWithCarousel = () => {
|
|||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch hero items (Continue Watching + Next Up combined)
|
||||||
|
const { data: heroItems } = useQuery({
|
||||||
|
queryKey: ["home", "heroItems", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||||
|
getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
fields: ["Overview"],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user.Id,
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 10,
|
||||||
|
fields: ["Overview"],
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resumeItems = resumeResponse.data.Items || [];
|
||||||
|
const nextUpItems = nextUpResponse.data.Items || [];
|
||||||
|
|
||||||
|
// Combine, sort by recent activity, and dedupe
|
||||||
|
const combined = [...resumeItems, ...nextUpItems];
|
||||||
|
const sorted = combined.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();
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: BaseItemDto[] = [];
|
||||||
|
for (const item of sorted) {
|
||||||
|
if (!item.Id || seen.has(item.Id)) continue;
|
||||||
|
seen.add(item.Id);
|
||||||
|
deduped.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped.slice(0, 8);
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userViews = useMemo(
|
const userViews = useMemo(
|
||||||
@@ -178,44 +271,36 @@ export const HomeWithCarousel = () => {
|
|||||||
);
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const _refetch = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
const createCollectionConfig = useCallback(
|
||||||
(
|
(
|
||||||
title: string,
|
title: string,
|
||||||
queryKey: string[],
|
queryKey: string[],
|
||||||
includeItemTypes: BaseItemKind[],
|
includeItemTypes: BaseItemKind[],
|
||||||
parentId: string | undefined,
|
parentId: string | undefined,
|
||||||
pageSize: number = 10,
|
pageSize = 10,
|
||||||
): InfiniteScrollingCollectionListSection => ({
|
): InfiniteScrollingCollectionListSection => ({
|
||||||
title,
|
title,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
const allData =
|
||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 100, // Fetch a larger set for pagination
|
limit: 10,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
).data || [];
|
).data || [];
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
pageSize,
|
pageSize,
|
||||||
|
parentId,
|
||||||
}),
|
}),
|
||||||
[api, user?.Id],
|
[api, user?.Id],
|
||||||
);
|
);
|
||||||
@@ -244,7 +329,6 @@ export const HomeWithCarousel = () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to sort items by most recent activity
|
|
||||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
return items.sort((a, b) => {
|
return items.sort((a, b) => {
|
||||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||||
@@ -253,7 +337,6 @@ export const HomeWithCarousel = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to deduplicate items by ID
|
|
||||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
@@ -263,29 +346,25 @@ export const HomeWithCarousel = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the first sections based on merge setting
|
|
||||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: t("home.continue_and_next_up"),
|
title: t("home.continue_and_next_up"),
|
||||||
queryKey: ["home", "continueAndNextUp"],
|
queryKey: ["home", "continueAndNextUp"],
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
// Fetch both in parallel
|
|
||||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||||
getItemsApi(api).getResumeItems({
|
getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: 0,
|
startIndex: 0,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
}),
|
}),
|
||||||
getTvShowsApi(api).getNextUp({
|
getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: 0,
|
startIndex: 0,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -293,17 +372,16 @@ export const HomeWithCarousel = () => {
|
|||||||
const resumeItems = resumeResponse.data.Items || [];
|
const resumeItems = resumeResponse.data.Items || [];
|
||||||
const nextUpItems = nextUpResponse.data.Items || [];
|
const nextUpItems = nextUpResponse.data.Items || [];
|
||||||
|
|
||||||
// Combine, sort by recent activity, deduplicate
|
|
||||||
const combined = [...resumeItems, ...nextUpItems];
|
const combined = [...resumeItems, ...nextUpItems];
|
||||||
const sorted = sortByRecentActivity(combined);
|
const sorted = sortByRecentActivity(combined);
|
||||||
const deduplicated = deduplicateById(sorted);
|
const deduplicated = deduplicateById(sorted);
|
||||||
|
|
||||||
// Paginate client-side
|
|
||||||
return deduplicated.slice(pageParam, pageParam + 10);
|
return deduplicated.slice(pageParam, pageParam + 10);
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@@ -314,9 +392,8 @@ export const HomeWithCarousel = () => {
|
|||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
@@ -324,6 +401,7 @@ export const HomeWithCarousel = () => {
|
|||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("home.next_up"),
|
title: t("home.next_up"),
|
||||||
@@ -332,23 +410,22 @@ export const HomeWithCarousel = () => {
|
|||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
...firstSections,
|
...firstSections,
|
||||||
...latestMediaViews,
|
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
||||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
|
||||||
...(!settings?.streamyStatsMovieRecommendations
|
...(!settings?.streamyStatsMovieRecommendations
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -367,6 +444,7 @@ export const HomeWithCarousel = () => {
|
|||||||
type: "InfiniteScrollingCollectionList" as const,
|
type: "InfiniteScrollingCollectionList" as const,
|
||||||
orientation: "vertical" as const,
|
orientation: "vertical" as const,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
priority: 2 as const,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -409,29 +487,26 @@ export const HomeWithCarousel = () => {
|
|||||||
if (section.nextUp) {
|
if (section.nextUp) {
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: section.nextUp?.limit || pageSize,
|
limit: section.nextUp?.limit || pageSize,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
});
|
});
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
}
|
}
|
||||||
if (section.latest) {
|
if (section.latest) {
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
const allData =
|
||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
limit: section.latest?.limit || 100, // Fetch larger set
|
limit: section.latest?.limit || 10,
|
||||||
isPlayed: section.latest?.isPlayed,
|
isPlayed: section.latest?.isPlayed,
|
||||||
groupItems: section.latest?.groupItems,
|
groupItems: section.latest?.groupItems,
|
||||||
})
|
})
|
||||||
).data || [];
|
).data || [];
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
}
|
}
|
||||||
if (section.custom) {
|
if (section.custom) {
|
||||||
@@ -454,6 +529,7 @@ export const HomeWithCarousel = () => {
|
|||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: section?.orientation || "vertical",
|
orientation: section?.orientation || "vertical",
|
||||||
pageSize,
|
pageSize,
|
||||||
|
priority: index < 2 ? 1 : 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return ss;
|
return ss;
|
||||||
@@ -461,6 +537,24 @@ export const HomeWithCarousel = () => {
|
|||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
|
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) {
|
if (!isConnected || serverConnected !== true) {
|
||||||
let title = "";
|
let title = "";
|
||||||
let subtitle = "";
|
let subtitle = "";
|
||||||
@@ -476,32 +570,44 @@ export const HomeWithCarousel = () => {
|
|||||||
subtitle = t("home.server_unreachable_message");
|
subtitle = t("home.server_unreachable_message");
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
<View
|
||||||
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
style={{
|
||||||
<Text className='text-center opacity-70'>{subtitle}</Text>
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
<View className='mt-4'>
|
justifyContent: "center",
|
||||||
{!Platform.isTV && (
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
<Button
|
}}
|
||||||
color='purple'
|
>
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
<Text
|
||||||
justify='center'
|
style={{
|
||||||
iconRight={
|
fontSize: typography.heading,
|
||||||
<Ionicons name='arrow-forward' size={20} color='white' />
|
fontWeight: "bold",
|
||||||
}
|
marginBottom: 8,
|
||||||
>
|
color: "#FFFFFF",
|
||||||
{t("home.go_to_downloads")}
|
}}
|
||||||
</Button>
|
>
|
||||||
)}
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ marginTop: 24 }}>
|
||||||
<Button
|
<Button
|
||||||
color='black'
|
color='black'
|
||||||
onPress={retryCheck}
|
onPress={retryCheck}
|
||||||
justify='center'
|
justify='center'
|
||||||
className='mt-2'
|
className='px-4'
|
||||||
iconRight={
|
iconRight={
|
||||||
retryLoading ? null : (
|
retryLoading ? null : (
|
||||||
<Ionicons name='refresh' size={20} color='white' />
|
<Ionicons name='refresh' size={24} color='white' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -518,9 +624,31 @@ export const HomeWithCarousel = () => {
|
|||||||
|
|
||||||
if (e1)
|
if (e1)
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full -mt-6'>
|
<View
|
||||||
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
|
style={{
|
||||||
<Text className='text-center opacity-70'>
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.oops")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("home.error_message")}
|
{t("home.error_message")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -528,55 +656,126 @@ export const HomeWithCarousel = () => {
|
|||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className='justify-center items-center h-full'>
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Determine if hero should be shown (separate setting from backdrop)
|
||||||
|
const showHero =
|
||||||
|
heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.ScrollView
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
scrollToOverflowEnabled={true}
|
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
|
||||||
ref={animatedScrollRef}
|
{!showHero && settings.showHomeBackdrop && (
|
||||||
nestedScrollEnabled
|
<View
|
||||||
contentInsetAdjustmentBehavior='never'
|
style={{
|
||||||
scrollEventThrottle={16}
|
position: "absolute",
|
||||||
bounces={false}
|
top: 0,
|
||||||
overScrollMode='never'
|
left: 0,
|
||||||
style={{ marginTop: -headerOverlayOffset }}
|
right: 0,
|
||||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
bottom: 0,
|
||||||
onScroll={(event) => {
|
}}
|
||||||
setScrollY(event.nativeEvent.contentOffset.y);
|
>
|
||||||
}}
|
{/* Layer 0 */}
|
||||||
>
|
<Animated.View
|
||||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
style={{
|
||||||
<View
|
position: "absolute",
|
||||||
style={{
|
width: "100%",
|
||||||
paddingLeft: insets.left,
|
height: "100%",
|
||||||
paddingRight: insets.right,
|
opacity: layer0Opacity,
|
||||||
paddingBottom: 16,
|
}}
|
||||||
paddingTop: 0,
|
>
|
||||||
|
{layer0Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer0Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Layer 1 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer1Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer1Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer1Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Gradient overlays for readability */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.4, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: showHero ? 0 : insets.top + TOP_PADDING,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4'>
|
{/* Hero Carousel - Apple TV+ style featured content */}
|
||||||
{sections.map((section, index) => {
|
{showHero && (
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
<TVHeroCarousel items={heroItems} onItemFocus={handleItemFocus} />
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
)}
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
|
||||||
? 0
|
<View
|
||||||
: 1;
|
style={{
|
||||||
|
gap: SECTION_GAP,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingTop: showHero ? SECTION_GAP : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
|
||||||
|
{sections.slice(showHero ? 1 : 0).map((section, index) => {
|
||||||
|
// Render Streamystats sections after Recently Added sections
|
||||||
|
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||||
|
// For custom sections: place at the very end
|
||||||
|
const hasSuggestedMovies =
|
||||||
|
!settings?.streamyStatsMovieRecommendations &&
|
||||||
|
!settings?.home?.sections;
|
||||||
|
// Adjust index calculation to account for sliced array when hero is shown
|
||||||
|
const displayedSectionsLength =
|
||||||
|
sections.length - (showHero ? 1 : 0);
|
||||||
|
const streamystatsIndex =
|
||||||
|
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||||
const hasStreamystatsContent =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
settings.streamyStatsPromotedWatchlists;
|
settings.streamyStatsPromotedWatchlists;
|
||||||
const streamystatsSections =
|
const streamystatsSections =
|
||||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||||
<>
|
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
|
||||||
{settings.streamyStatsMovieRecommendations && (
|
{settings.streamyStatsMovieRecommendations && (
|
||||||
<StreamystatsRecommendations
|
<StreamystatsRecommendations
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.recommended_movies",
|
"home.settings.plugins.streamystats.recommended_movies",
|
||||||
)}
|
)}
|
||||||
type='Movie'
|
type='Movie'
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{settings.streamyStatsSeriesRecommendations && (
|
{settings.streamyStatsSeriesRecommendations && (
|
||||||
@@ -585,17 +784,25 @@ export const HomeWithCarousel = () => {
|
|||||||
"home.settings.plugins.streamystats.recommended_series",
|
"home.settings.plugins.streamystats.recommended_series",
|
||||||
)}
|
)}
|
||||||
type='Series'
|
type='Series'
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{settings.streamyStatsPromotedWatchlists && (
|
{settings.streamyStatsPromotedWatchlists && (
|
||||||
<StreamystatsPromotedWatchlists />
|
<StreamystatsPromotedWatchlists
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
|
const isHighPriority = section.priority === 1;
|
||||||
|
// First section only gets preferred focus if hero is not shown
|
||||||
|
const isFirstSection = index === 0 && !showHero;
|
||||||
return (
|
return (
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
<View key={index} style={{ gap: SECTION_GAP }}>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
@@ -603,19 +810,15 @@ export const HomeWithCarousel = () => {
|
|||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={section.pageSize}
|
pageSize={section.pageSize}
|
||||||
/>
|
enabled={isHighPriority || allHighPriorityLoaded}
|
||||||
{streamystatsSections}
|
onLoaded={
|
||||||
</View>
|
isHighPriority
|
||||||
);
|
? () => markSectionLoaded(section.queryKey)
|
||||||
}
|
: undefined
|
||||||
if (section.type === "MediaListSection") {
|
}
|
||||||
return (
|
isFirstSection={isFirstSection}
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
onItemFocus={handleItemFocus}
|
||||||
<MediaListSection
|
parentId={section.parentId}
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
scrollY={scrollY}
|
|
||||||
enableLazyLoading={true}
|
|
||||||
/>
|
/>
|
||||||
{streamystatsSections}
|
{streamystatsSections}
|
||||||
</View>
|
</View>
|
||||||
@@ -624,8 +827,7 @@ export const HomeWithCarousel = () => {
|
|||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
<View className='h-24' />
|
</View>
|
||||||
</Animated.ScrollView>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
498
components/home/InfiniteScrollingCollectionList.tv.tsx
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
type QueryFunction,
|
||||||
|
type QueryKey,
|
||||||
|
useInfiniteQuery,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { useSegments } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
|
import ContinueWatchingPoster, {
|
||||||
|
TV_LANDSCAPE_WIDTH,
|
||||||
|
} from "../ContinueWatchingPoster.tv";
|
||||||
|
import SeriesPoster from "../posters/SeriesPoster.tv";
|
||||||
|
|
||||||
|
const ITEM_GAP = 24;
|
||||||
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title?: string | null;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
disabled?: boolean;
|
||||||
|
queryKey: QueryKey;
|
||||||
|
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
|
||||||
|
hideIfEmpty?: boolean;
|
||||||
|
pageSize?: number;
|
||||||
|
onPressSeeAll?: () => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
onLoaded?: () => void;
|
||||||
|
isFirstSection?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
// TV-specific ItemCardText with larger fonts
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
{item.Type === "Episode" ? (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
|
{" - "}
|
||||||
|
{item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TV-specific "See All" card for end of lists
|
||||||
|
const TVSeeAllCard: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
orientation: "horizontal" | "vertical";
|
||||||
|
disabled?: boolean;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const width =
|
||||||
|
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||||
|
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ width }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
aspectRatio,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='arrow-forward'
|
||||||
|
size={32}
|
||||||
|
color='white'
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.seeAll", { defaultValue: "See all" })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TVFocusablePoster>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
orientation = "vertical",
|
||||||
|
disabled = false,
|
||||||
|
queryFn,
|
||||||
|
queryKey,
|
||||||
|
hideIfEmpty = false,
|
||||||
|
pageSize = 10,
|
||||||
|
enabled = true,
|
||||||
|
onLoaded,
|
||||||
|
isFirstSection = false,
|
||||||
|
onItemFocus,
|
||||||
|
parentId,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const effectivePageSize = Math.max(1, pageSize);
|
||||||
|
const hasCalledOnLoaded = useRef(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
|
// Track focus within section for item focus/blur callbacks
|
||||||
|
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||||
|
const [_focusedCount, setFocusedCount] = useState(0);
|
||||||
|
|
||||||
|
const handleItemFocus = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
setFocusedCount((c) => c + 1);
|
||||||
|
onItemFocus?.(item);
|
||||||
|
},
|
||||||
|
[onItemFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleItemBlur = useCallback(() => {
|
||||||
|
setFocusedCount((c) => Math.max(0, c - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus handler for See All card (doesn't need item parameter)
|
||||||
|
const handleSeeAllFocus = useCallback(() => {
|
||||||
|
setFocusedCount((c) => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isSuccess,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: queryKey,
|
||||||
|
queryFn: ({ pageParam = 0, ...context }) =>
|
||||||
|
queryFn({ ...context, queryKey, pageParam }),
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
if (lastPage.length < effectivePageSize) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return allPages.reduce((acc, page) => acc + page.length, 0);
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
|
||||||
|
hasCalledOnLoaded.current = true;
|
||||||
|
onLoaded();
|
||||||
|
}
|
||||||
|
}, [isSuccess, onLoaded]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const allItems = useMemo(() => {
|
||||||
|
const items = data?.pages.flat() ?? [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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 itemWidth =
|
||||||
|
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||||
|
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
const handleSeeAllPress = useCallback(() => {
|
||||||
|
if (!parentId) return;
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||||
|
params: {
|
||||||
|
libraryId: parentId,
|
||||||
|
sortBy: SortByOption.DateCreated,
|
||||||
|
sortOrder: SortOrderOption.Descending,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}, [router, parentId]);
|
||||||
|
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: itemWidth + ITEM_GAP,
|
||||||
|
offset: (itemWidth + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[itemWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||||
|
const isFirstItem = isFirstSection && index === 0;
|
||||||
|
const isHorizontal = orientation === "horizontal";
|
||||||
|
|
||||||
|
const renderPoster = () => {
|
||||||
|
if (item.Type === "Episode" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode" && !isHorizontal) {
|
||||||
|
return <SeriesPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Movie" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Movie" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Series" && !isHorizontal) {
|
||||||
|
return <SeriesPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Series" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "BoxSet" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "BoxSet" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Playlist" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Playlist" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Video" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Video" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
// Default fallback
|
||||||
|
return isHorizontal ? (
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
) : (
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(item)}
|
||||||
|
hasTVPreferredFocus={isFirstItem}
|
||||||
|
onFocus={() => handleItemFocus(item)}
|
||||||
|
onBlur={handleItemBlur}
|
||||||
|
>
|
||||||
|
{renderPoster()}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} typography={typography} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
orientation,
|
||||||
|
isFirstSection,
|
||||||
|
itemWidth,
|
||||||
|
handleItemPress,
|
||||||
|
handleItemFocus,
|
||||||
|
handleItemBlur,
|
||||||
|
typography,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
||||||
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
{/* Section Header */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading === false && allItems.length === 0 && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#737373",
|
||||||
|
fontSize: typography.callout,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.no_items")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: ITEM_GAP,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<View key={i} style={{ width: itemWidth }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
width: itemWidth,
|
||||||
|
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: 4,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#262626",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: typography.callout,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
Placeholder text here
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
horizontal
|
||||||
|
data={allItems}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={renderItem}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
initialNumToRender={5}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
ListFooterComponent={
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginLeft: itemWidth / 2,
|
||||||
|
marginRight: ITEM_GAP,
|
||||||
|
justifyContent: "center",
|
||||||
|
height: orientation === "horizontal" ? 191 : 315,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{parentId && allItems.length > 0 && (
|
||||||
|
<TVSeeAllCard
|
||||||
|
onPress={handleSeeAllPress}
|
||||||
|
orientation={orientation}
|
||||||
|
disabled={disabled}
|
||||||
|
onFocus={handleSeeAllFocus}
|
||||||
|
onBlur={handleItemBlur}
|
||||||
|
typography={typography}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -44,7 +44,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled: enableLazyLoading ? isInView : true,
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,7 +214,6 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
349
components/home/StreamystatsPromotedWatchlists.tv.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
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 { useSegments } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { FlatList, View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||||
|
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WatchlistSectionProps extends ViewProps {
|
||||||
|
watchlist: StreamystatsWatchlist;
|
||||||
|
jellyfinServerId: string;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||||
|
watchlist,
|
||||||
|
jellyfinServerId,
|
||||||
|
onItemFocus,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
|
const { data: items, isLoading } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"watchlist",
|
||||||
|
watchlist.id,
|
||||||
|
jellyfinServerId,
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||||
|
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: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: TV_POSTER_WIDTH + ITEM_GAP,
|
||||||
|
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(item)}
|
||||||
|
onFocus={() => onItemFocus?.(item)}
|
||||||
|
hasTVPreferredFocus={false}
|
||||||
|
>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} typography={typography} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleItemPress, onItemFocus, typography],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: ITEM_GAP,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={renderItem}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={5}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
||||||
|
enabled?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StreamystatsPromotedWatchlists: React.FC<
|
||||||
|
StreamystatsPromotedWatchlistsProps
|
||||||
|
> = ({ enabled = true, onItemFocus, ...props }) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const streamyStatsEnabled = useMemo(() => {
|
||||||
|
return Boolean(settings?.streamyStatsServerUrl);
|
||||||
|
}, [settings?.streamyStatsServerUrl]);
|
||||||
|
|
||||||
|
const { data: serverInfo } = useQuery({
|
||||||
|
queryKey: ["jellyfin", "serverInfo"],
|
||||||
|
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||||
|
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<StreamystatsWatchlist[]> => {
|
||||||
|
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: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!streamyStatsEnabled) return null;
|
||||||
|
if (isError) return null;
|
||||||
|
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 16,
|
||||||
|
width: 128,
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: ITEM_GAP,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{watchlists?.map((watchlist) => (
|
||||||
|
<WatchlistSection
|
||||||
|
key={watchlist.id}
|
||||||
|
watchlist={watchlist}
|
||||||
|
jellyfinServerId={jellyfinServerId!}
|
||||||
|
onItemFocus={onItemFocus}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -103,7 +103,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +135,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
enabled:
|
enabled:
|
||||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
282
components/home/StreamystatsRecommendations.tv.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
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 { useSegments } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { FlatList, View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title: string;
|
||||||
|
type: "Movie" | "Series";
|
||||||
|
limit?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
limit = 20,
|
||||||
|
enabled = true,
|
||||||
|
onItemFocus,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
|
const streamyStatsEnabled = useMemo(() => {
|
||||||
|
return Boolean(settings?.streamyStatsServerUrl);
|
||||||
|
}, [settings?.streamyStatsServerUrl]);
|
||||||
|
|
||||||
|
const { data: serverInfo } = useQuery({
|
||||||
|
queryKey: ["jellyfin", "serverInfo"],
|
||||||
|
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||||
|
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: recommendationIds,
|
||||||
|
isLoading: isLoadingRecommendations,
|
||||||
|
isError: isRecommendationsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"recommendations",
|
||||||
|
type,
|
||||||
|
jellyfinServerId,
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<string[]> => {
|
||||||
|
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: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: items,
|
||||||
|
isLoading: isLoadingItems,
|
||||||
|
isError: isItemsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"recommendations",
|
||||||
|
"items",
|
||||||
|
type,
|
||||||
|
recommendationIds,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||||
|
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: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = isLoadingRecommendations || isLoadingItems;
|
||||||
|
const isError = isRecommendationsError || isItemsError;
|
||||||
|
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: TV_POSTER_WIDTH + ITEM_GAP,
|
||||||
|
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(item)}
|
||||||
|
onFocus={() => onItemFocus?.(item)}
|
||||||
|
hasTVPreferredFocus={false}
|
||||||
|
>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} typography={typography} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleItemPress, onItemFocus, typography],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!streamyStatsEnabled) return null;
|
||||||
|
if (isError) return null;
|
||||||
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: ITEM_GAP,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={renderItem}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={5}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
624
components/home/TVHeroCarousel.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Easing,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
const HERO_HEIGHT = SCREEN_HEIGHT * 0.62;
|
||||||
|
const CARD_WIDTH = 280;
|
||||||
|
const CARD_GAP = 24;
|
||||||
|
const CARD_PADDING = 60;
|
||||||
|
|
||||||
|
interface TVHeroCarouselProps {
|
||||||
|
items: BaseItemDto[];
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroCardProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
isFirst: boolean;
|
||||||
|
onFocus: (item: BaseItemDto) => void;
|
||||||
|
onPress: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||||
|
({ item, isFirst, onFocus, onPress }) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
// Check if glass effect is available (tvOS 26+)
|
||||||
|
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
||||||
|
|
||||||
|
const posterUrl = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
// For episodes, always use series thumb
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
if (item.ParentThumbImageTag) {
|
||||||
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
if (item.SeriesId) {
|
||||||
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-episodes, use item's own thumb/primary
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
|
||||||
|
}
|
||||||
|
if (item.ImageTags?.Primary) {
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const progress = item.UserData?.PlayedPercentage || 0;
|
||||||
|
|
||||||
|
const animateTo = useCallback(
|
||||||
|
(value: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: value,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(),
|
||||||
|
[scale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.1);
|
||||||
|
onFocus(item);
|
||||||
|
}, [animateTo, onFocus, item]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}, [animateTo]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onPress(item);
|
||||||
|
}, [onPress, item]);
|
||||||
|
|
||||||
|
// Use glass poster for tvOS 26+
|
||||||
|
if (useGlass) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
style={{ marginRight: CARD_GAP }}
|
||||||
|
>
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={posterUrl}
|
||||||
|
aspectRatio={16 / 9}
|
||||||
|
cornerRadius={16}
|
||||||
|
progress={progress}
|
||||||
|
showWatchedIndicator={false}
|
||||||
|
isFocused={focused}
|
||||||
|
width={CARD_WIDTH}
|
||||||
|
style={{ width: CARD_WIDTH }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for non-tvOS or older tvOS
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
style={{ marginRight: CARD_GAP }}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#FFFFFF",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='film-outline'
|
||||||
|
size={48}
|
||||||
|
color='rgba(255,255,255,0.3)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ProgressBar item={item} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounce delay to prevent rapid backdrop changes when navigating fast
|
||||||
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||||
|
items,
|
||||||
|
onItemFocus,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Active item for featured display (debounced)
|
||||||
|
const [activeItem, setActiveItem] = useState<BaseItemDto | null>(
|
||||||
|
items[0] || null,
|
||||||
|
);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Cleanup debounce timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Crossfade animation state
|
||||||
|
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
|
||||||
|
const [layer0Url, setLayer0Url] = useState<string | null>(null);
|
||||||
|
const [layer1Url, setLayer1Url] = useState<string | null>(null);
|
||||||
|
const layer0Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const layer1Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// Get backdrop URL for active item
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
if (!activeItem) return null;
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: activeItem,
|
||||||
|
quality: 90,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, activeItem]);
|
||||||
|
|
||||||
|
// Get logo URL for active item
|
||||||
|
const logoUrl = useMemo(() => {
|
||||||
|
if (!activeItem) return null;
|
||||||
|
return getLogoImageUrlById({ api, item: activeItem });
|
||||||
|
}, [api, activeItem]);
|
||||||
|
|
||||||
|
// Crossfade effect for backdrop
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backdropUrl) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const performCrossfade = async () => {
|
||||||
|
try {
|
||||||
|
await Image.prefetch(backdropUrl);
|
||||||
|
} catch {
|
||||||
|
// Continue even if prefetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const incomingLayer = activeLayer === 0 ? 1 : 0;
|
||||||
|
const incomingOpacity =
|
||||||
|
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
|
||||||
|
const outgoingOpacity =
|
||||||
|
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
|
||||||
|
|
||||||
|
if (incomingLayer === 0) {
|
||||||
|
setLayer0Url(backdropUrl);
|
||||||
|
} else {
|
||||||
|
setLayer1Url(backdropUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(incomingOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(outgoingOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setActiveLayer(incomingLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
performCrossfade();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [backdropUrl]);
|
||||||
|
|
||||||
|
// Handle card focus with debounce
|
||||||
|
const handleCardFocus = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
// Clear any pending debounce timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
// Set new timer to update active item after debounce delay
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setActiveItem(item);
|
||||||
|
onItemFocus?.(item);
|
||||||
|
}, BACKDROP_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
[onItemFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle card press - navigate to item
|
||||||
|
const handleCardPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get metadata for active item
|
||||||
|
const year = activeItem?.ProductionYear;
|
||||||
|
const duration = activeItem?.RunTimeTicks
|
||||||
|
? runtimeTicksToMinutes(activeItem.RunTimeTicks)
|
||||||
|
: null;
|
||||||
|
const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||||
|
const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0;
|
||||||
|
|
||||||
|
// Get display title
|
||||||
|
const displayTitle = useMemo(() => {
|
||||||
|
if (!activeItem) return "";
|
||||||
|
if (activeItem.Type === "Episode") {
|
||||||
|
return activeItem.SeriesName || activeItem.Name || "";
|
||||||
|
}
|
||||||
|
return activeItem.Name || "";
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// Get subtitle for episodes
|
||||||
|
const episodeSubtitle = useMemo(() => {
|
||||||
|
if (!activeItem || activeItem.Type !== "Episode") return null;
|
||||||
|
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// Memoize hero items to prevent re-renders
|
||||||
|
const heroItems = useMemo(() => items.slice(0, 8), [items]);
|
||||||
|
|
||||||
|
// Memoize renderItem for FlatList
|
||||||
|
const renderHeroCard = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<HeroCard
|
||||||
|
item={item}
|
||||||
|
isFirst={index === 0}
|
||||||
|
onFocus={handleCardFocus}
|
||||||
|
onPress={handleCardPress}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[handleCardFocus, handleCardPress],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize keyExtractor
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ height: HERO_HEIGHT, width: "100%" }}>
|
||||||
|
{/* Backdrop layers with crossfade */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Layer 0 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer0Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer0Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer0Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Layer 1 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer1Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer1Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer1Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Gradient overlays */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.5)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.4)", "transparent"]}
|
||||||
|
locations={[0, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: "40%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Horizontal gradient for left side text contrast */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.9)", "rgba(0,0,0,0.6)", "transparent"]}
|
||||||
|
locations={[0, 0.5, 0.85]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content overlay */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: insets.left + CARD_PADDING,
|
||||||
|
right: insets.right + CARD_PADDING,
|
||||||
|
bottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo or Title */}
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logoUrl }}
|
||||||
|
style={{
|
||||||
|
height: 100,
|
||||||
|
width: SCREEN_WIDTH * 0.35,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
contentPosition='left'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.display,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Episode subtitle */}
|
||||||
|
{episodeSubtitle && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{episodeSubtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{activeItem?.Overview && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
marginBottom: 16,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.5,
|
||||||
|
lineHeight: typography.body * 1.4,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{activeItem.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata badges */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{duration && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{duration}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{activeItem?.OfficialRating && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255,255,255,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeItem.OfficialRating}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{hasProgress && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 60,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.3)",
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${playedPercent}%`,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(playedPercent)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Thumbnail carousel */}
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={heroItems}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{ paddingVertical: 12 }}
|
||||||
|
renderItem={renderHeroCard}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
initialNumToRender={8}
|
||||||
|
maxToRenderPerBatch={8}
|
||||||
|
windowSize={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
141
components/inputs/TVPinInput.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
type TextInputProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface TVPinInputProps
|
||||||
|
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
length?: number;
|
||||||
|
label?: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TVPinInputRef {
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVPinInputComponent = React.forwardRef<TVPinInputRef, TVPinInputProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
length = 4,
|
||||||
|
label,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
placeholder,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
focus: () => inputRef.current?.focus(),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.02 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeText = (text: string) => {
|
||||||
|
// Only allow numeric input and limit to length
|
||||||
|
const numericText = text.replace(/[^0-9]/g, "").slice(0, length);
|
||||||
|
onChangeText(numericText);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => inputRef.current?.focus()}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
borderColor: isFocused ? "#6366F1" : "#374151",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label && <Text style={styles.label}>{label}</Text>}
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChangeText={handleChangeText}
|
||||||
|
keyboardType='number-pad'
|
||||||
|
maxLength={length}
|
||||||
|
secureTextEntry
|
||||||
|
placeholder={placeholder || `Enter ${length}-digit PIN`}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
style={styles.input}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
TVPinInputComponent.displayName = "TVPinInput";
|
||||||
|
|
||||||
|
export const TVPinInput = TVPinInputComponent;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: "#1F2937",
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 4,
|
||||||
|
minWidth: 280,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 4,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "500",
|
||||||
|
textAlign: "center",
|
||||||
|
height: 56,
|
||||||
|
letterSpacing: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
47
components/jellyseerr/discover/TVDiscover.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { sortBy } from "lodash";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
|
import { TVDiscoverSlide } from "./TVDiscoverSlide";
|
||||||
|
|
||||||
|
interface TVDiscoverProps {
|
||||||
|
sliders?: DiscoverSlider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show movie/TV slides on TV - skip genres, networks, studios for now
|
||||||
|
const SUPPORTED_SLIDE_TYPES = [
|
||||||
|
DiscoverSliderType.TRENDING,
|
||||||
|
DiscoverSliderType.POPULAR_MOVIES,
|
||||||
|
DiscoverSliderType.UPCOMING_MOVIES,
|
||||||
|
DiscoverSliderType.POPULAR_TV,
|
||||||
|
DiscoverSliderType.UPCOMING_TV,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TVDiscover: React.FC<TVDiscoverProps> = ({ sliders }) => {
|
||||||
|
const sortedSliders = useMemo(
|
||||||
|
() =>
|
||||||
|
sortBy(
|
||||||
|
(sliders ?? []).filter(
|
||||||
|
(s) => s.enabled && SUPPORTED_SLIDE_TYPES.includes(s.type),
|
||||||
|
),
|
||||||
|
"order",
|
||||||
|
"asc",
|
||||||
|
),
|
||||||
|
[sliders],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sliders || sortedSliders.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{sortedSliders.map((slide, index) => (
|
||||||
|
<TVDiscoverSlide
|
||||||
|
key={slide.id}
|
||||||
|
slide={slide}
|
||||||
|
isFirstSlide={index === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
270
components/jellyseerr/discover/TVDiscoverSlide.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
type DiscoverEndpoint,
|
||||||
|
Endpoints,
|
||||||
|
useJellyseerr,
|
||||||
|
} from "@/hooks/useJellyseerr";
|
||||||
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
interface TVDiscoverPosterProps {
|
||||||
|
item: MovieResult | TvResult;
|
||||||
|
isFirstItem?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||||
|
item,
|
||||||
|
isFirstItem = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
|
const posterUrl = item.posterPath
|
||||||
|
? jellyseerrApi?.imageProxy(item.posterPath, "w342")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const title = getTitle(item);
|
||||||
|
const year = getYear(item);
|
||||||
|
|
||||||
|
const isInLibrary =
|
||||||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
|
params: {
|
||||||
|
id: String(item.id),
|
||||||
|
mediaType: item.mediaType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirstItem}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
width: 210,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 210,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='image-outline'
|
||||||
|
size={40}
|
||||||
|
color='rgba(255,255,255,0.3)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isInLibrary && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.9)",
|
||||||
|
borderRadius: 14,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='checkmark' size={18} color='black' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{year && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVDiscoverSlideProps {
|
||||||
|
slide: DiscoverSlider;
|
||||||
|
isFirstSlide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||||
|
slide,
|
||||||
|
isFirstSlide = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ["jellyseerr", "discover", "tv", slide.id],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
let endpoint: DiscoverEndpoint | undefined;
|
||||||
|
let params: Record<string, unknown> = {
|
||||||
|
page: Number(pageParam),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (slide.type) {
|
||||||
|
case DiscoverSliderType.TRENDING:
|
||||||
|
endpoint = Endpoints.DISCOVER_TRENDING;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_MOVIES:
|
||||||
|
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||||
|
endpoint = Endpoints.DISCOVER_MOVIES;
|
||||||
|
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_TV:
|
||||||
|
case DiscoverSliderType.UPCOMING_TV:
|
||||||
|
endpoint = Endpoints.DISCOVER_TV;
|
||||||
|
if (slide.type === DiscoverSliderType.UPCOMING_TV)
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
firstAirDateGte: new Date().toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, pages) =>
|
||||||
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
|
1,
|
||||||
|
enabled: !!jellyseerrApi,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(
|
||||||
|
() =>
|
||||||
|
uniqBy(
|
||||||
|
data?.pages
|
||||||
|
?.filter((p) => p?.results.length)
|
||||||
|
.flatMap((p) =>
|
||||||
|
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
|
||||||
|
),
|
||||||
|
"id",
|
||||||
|
) as (MovieResult | TvResult)[],
|
||||||
|
[data, isJellyseerrMovieOrTvResult],
|
||||||
|
);
|
||||||
|
|
||||||
|
const slideTitle = t(
|
||||||
|
`search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!flatData || flatData.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slideTitle}
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={flatData}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) fetchNextPage();
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TVDiscoverPoster
|
||||||
|
item={item}
|
||||||
|
isFirstItem={isFirstSlide && index === 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
877
components/jellyseerr/tv/TVJellyseerrPage.tsx
Normal file
@@ -0,0 +1,877 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import { TVButton } from "@/components/tv";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||||
|
import { useTVSeasonSelectModal } from "@/hooks/useTVSeasonSelectModal";
|
||||||
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import type Season from "@/utils/jellyseerr/server/entity/Season";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
// Cast card component
|
||||||
|
interface TVCastCardProps {
|
||||||
|
person: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
character?: string;
|
||||||
|
profilePath?: string;
|
||||||
|
};
|
||||||
|
imageProxy: (path: string, size?: string) => string;
|
||||||
|
onPress: () => void;
|
||||||
|
refSetter?: (ref: View | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||||
|
person,
|
||||||
|
imageProxy,
|
||||||
|
onPress,
|
||||||
|
refSetter,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
|
const profileUrl = person.profilePath
|
||||||
|
? imageProxy(person.profilePath, "w185")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={refSetter}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
width: 140,
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: focused ? 3 : 0,
|
||||||
|
borderColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profileUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: profileUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='person' size={48} color='rgba(255,255,255,0.4)' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{person.name}
|
||||||
|
</Text>
|
||||||
|
{person.character && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: focused
|
||||||
|
? "rgba(255,255,255,0.7)"
|
||||||
|
: "rgba(255,255,255,0.5)",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{person.character}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVJellyseerrPage: React.FC = () => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||||
|
params as unknown as {
|
||||||
|
mediaTitle: string;
|
||||||
|
releaseYear: number;
|
||||||
|
canRequest: string;
|
||||||
|
posterSrc: string;
|
||||||
|
mediaType: MediaType;
|
||||||
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
const { showRequestModal } = useTVRequestModal();
|
||||||
|
const { showSeasonSelectModal } = useTVSeasonSelectModal();
|
||||||
|
|
||||||
|
// Refs for TVFocusGuideView destinations (useState triggers re-render when set)
|
||||||
|
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
|
||||||
|
const [firstCastCardRef, setFirstCastCardRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: details,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
|
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
queryFn: async () => {
|
||||||
|
return mediaType === MediaType.MOVIE
|
||||||
|
? jellyseerrApi?.movieDetails(result.id!)
|
||||||
|
: jellyseerrApi?.tvDetails(result.id!);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
|
const canManageRequests = useMemo(() => {
|
||||||
|
if (!jellyseerrUser) return false;
|
||||||
|
return hasPermission(
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
);
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const pendingRequest = useMemo(() => {
|
||||||
|
return details?.mediaInfo?.requests?.find(
|
||||||
|
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||||
|
);
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
// Get seasons with status for TV shows
|
||||||
|
const seasons = useMemo(() => {
|
||||||
|
if (!details || mediaType !== MediaType.TV) return [];
|
||||||
|
const tvDetails = details as TvDetails;
|
||||||
|
const mediaInfoSeasons = tvDetails.mediaInfo?.seasons?.filter(
|
||||||
|
(s: Season) => s.seasonNumber !== 0,
|
||||||
|
);
|
||||||
|
const requestedSeasons =
|
||||||
|
tvDetails.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ??
|
||||||
|
[];
|
||||||
|
return (
|
||||||
|
tvDetails.seasons?.map((season) => ({
|
||||||
|
...season,
|
||||||
|
status:
|
||||||
|
mediaInfoSeasons?.find(
|
||||||
|
(mediaSeason: Season) =>
|
||||||
|
mediaSeason.seasonNumber === season.seasonNumber,
|
||||||
|
)?.status ??
|
||||||
|
requestedSeasons?.find(
|
||||||
|
(s: Season) => s.seasonNumber === season.seasonNumber,
|
||||||
|
)?.status ??
|
||||||
|
MediaStatus.UNKNOWN,
|
||||||
|
})) ?? []
|
||||||
|
);
|
||||||
|
}, [details, mediaType]);
|
||||||
|
|
||||||
|
const _allSeasonsAvailable = useMemo(
|
||||||
|
() => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
|
||||||
|
[seasons],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if there are any requestable seasons (status === UNKNOWN)
|
||||||
|
const hasRequestableSeasons = useMemo(
|
||||||
|
() =>
|
||||||
|
seasons.some(
|
||||||
|
(season) =>
|
||||||
|
season.seasonNumber !== 0 && season.status === MediaStatus.UNKNOWN,
|
||||||
|
),
|
||||||
|
[seasons],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get cast
|
||||||
|
const cast = useMemo(() => {
|
||||||
|
return details?.credits?.cast?.slice(0, 10) ?? [];
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
// Backdrop URL
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
const path = details?.backdropPath || result.backdropPath;
|
||||||
|
return path
|
||||||
|
? jellyseerrApi?.imageProxy(path, "w1920_and_h800_multi_faces")
|
||||||
|
: null;
|
||||||
|
}, [details, result.backdropPath, jellyseerrApi]);
|
||||||
|
|
||||||
|
// Poster URL
|
||||||
|
const posterUrl = useMemo(() => {
|
||||||
|
if (posterSrc) return posterSrc;
|
||||||
|
const path = details?.posterPath;
|
||||||
|
return path ? jellyseerrApi?.imageProxy(path, "w342") : null;
|
||||||
|
}, [posterSrc, details, jellyseerrApi]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleApproveRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||||
|
refetch();
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||||
|
refetch();
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleRequest = useCallback(async () => {
|
||||||
|
const body: MediaRequestBody = {
|
||||||
|
mediaId: Number(result.id!),
|
||||||
|
mediaType: mediaType!,
|
||||||
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
|
...(mediaType === MediaType.TV && {
|
||||||
|
seasons: (details as TvDetails)?.seasons
|
||||||
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
|
?.map?.((s) => s.seasonNumber),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasAdvancedRequestPermission) {
|
||||||
|
showRequestModal({
|
||||||
|
requestBody: body,
|
||||||
|
title: mediaTitle,
|
||||||
|
id: result.id!,
|
||||||
|
mediaType: mediaType!,
|
||||||
|
onRequested: refetch,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMedia(mediaTitle, body, refetch);
|
||||||
|
}, [
|
||||||
|
details,
|
||||||
|
result,
|
||||||
|
requestMedia,
|
||||||
|
hasAdvancedRequestPermission,
|
||||||
|
mediaTitle,
|
||||||
|
refetch,
|
||||||
|
mediaType,
|
||||||
|
showRequestModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRequestAll = useCallback(() => {
|
||||||
|
const body: MediaRequestBody = {
|
||||||
|
mediaId: Number(result.id!),
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
|
seasons: seasons
|
||||||
|
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
|
||||||
|
.map((s) => s.seasonNumber),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasAdvancedRequestPermission) {
|
||||||
|
showRequestModal({
|
||||||
|
requestBody: body,
|
||||||
|
title: mediaTitle,
|
||||||
|
id: result.id!,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
onRequested: refetch,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMedia(`${mediaTitle}, ${t("jellyseerr.season_all")}`, body, refetch);
|
||||||
|
}, [
|
||||||
|
details,
|
||||||
|
result,
|
||||||
|
seasons,
|
||||||
|
hasAdvancedRequestPermission,
|
||||||
|
requestMedia,
|
||||||
|
mediaTitle,
|
||||||
|
refetch,
|
||||||
|
t,
|
||||||
|
showRequestModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleOpenSeasonSelectModal = useCallback(() => {
|
||||||
|
showSeasonSelectModal({
|
||||||
|
seasons: seasons.filter((s) => s.seasonNumber !== 0),
|
||||||
|
title: mediaTitle,
|
||||||
|
mediaId: Number(result.id!),
|
||||||
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
|
hasAdvancedRequestPermission,
|
||||||
|
onRequested: refetch,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
seasons,
|
||||||
|
mediaTitle,
|
||||||
|
result,
|
||||||
|
details,
|
||||||
|
hasAdvancedRequestPermission,
|
||||||
|
refetch,
|
||||||
|
showSeasonSelectModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId;
|
||||||
|
if (!jellyfinMediaId) return;
|
||||||
|
router.push({
|
||||||
|
pathname:
|
||||||
|
mediaType === MediaType.MOVIE
|
||||||
|
? "/(auth)/(tabs)/(search)/items/page"
|
||||||
|
: "/(auth)/(tabs)/(search)/series/[id]",
|
||||||
|
params: { id: jellyfinMediaId },
|
||||||
|
});
|
||||||
|
}, [details, mediaType, router]);
|
||||||
|
|
||||||
|
const handleCastPress = useCallback(
|
||||||
|
(personId: number) => {
|
||||||
|
router.push(`/(auth)/jellyseerr/person/${personId}` as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasJellyfinMedia = !!details?.mediaInfo?.jellyfinMediaId;
|
||||||
|
const requestedByName =
|
||||||
|
pendingRequest?.requestedBy?.displayName ||
|
||||||
|
pendingRequest?.requestedBy?.username ||
|
||||||
|
pendingRequest?.requestedBy?.jellyfinUsername ||
|
||||||
|
t("jellyseerr.unknown_user");
|
||||||
|
|
||||||
|
if (isLoading || isFetching) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000" }}>
|
||||||
|
{/* Full-screen backdrop */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backdropUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: backdropUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
|
||||||
|
)}
|
||||||
|
{/* Bottom gradient */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Left gradient */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.8)", "transparent"]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0.6, y: 0 }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "60%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 140,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Top section - Poster + Content */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
minHeight: SCREEN_HEIGHT * 0.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Poster */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: SCREEN_WIDTH * 0.22,
|
||||||
|
marginRight: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='image-outline'
|
||||||
|
size={48}
|
||||||
|
color='rgba(255,255,255,0.3)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Content */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Ratings */}
|
||||||
|
{details && (
|
||||||
|
<JellyserrRatings
|
||||||
|
result={
|
||||||
|
details as MovieDetails | TvDetails | MovieResult | TvResult
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.display,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{mediaTitle}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Year */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{releaseYear}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{details?.genres && details.genres.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<GenreTags genres={details.genres.map((g) => g.name)} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
{(details?.overview || result.overview) && (
|
||||||
|
<BlurView
|
||||||
|
intensity={10}
|
||||||
|
tint='light'
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#E5E7EB",
|
||||||
|
lineHeight: 32,
|
||||||
|
}}
|
||||||
|
numberOfLines={4}
|
||||||
|
>
|
||||||
|
{details?.overview || result.overview}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasJellyfinMedia && (
|
||||||
|
<TVButton
|
||||||
|
onPress={handlePlay}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
variant='primary'
|
||||||
|
refSetter={setPlayButtonRef}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='play'
|
||||||
|
size={28}
|
||||||
|
color='#000000'
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#000000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.play")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Request button - only show for movies, TV series use Request All + season cards */}
|
||||||
|
{canRequest && mediaType === MediaType.MOVIE && (
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequest}
|
||||||
|
variant='secondary'
|
||||||
|
hasTVPreferredFocus={!hasJellyfinMedia}
|
||||||
|
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
||||||
|
scaleAmount={1.01}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={24}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_button")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Request All button for TV series */}
|
||||||
|
{mediaType === MediaType.TV &&
|
||||||
|
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
|
||||||
|
hasRequestableSeasons && (
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequestAll}
|
||||||
|
variant='secondary'
|
||||||
|
hasTVPreferredFocus={!hasJellyfinMedia}
|
||||||
|
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='bag-add'
|
||||||
|
size={20}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_all")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TVButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Request Seasons button for TV series */}
|
||||||
|
{mediaType === MediaType.TV &&
|
||||||
|
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
|
||||||
|
hasRequestableSeasons && (
|
||||||
|
<TVButton
|
||||||
|
onPress={handleOpenSeasonSelectModal}
|
||||||
|
variant='secondary'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='list'
|
||||||
|
size={20}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_seasons")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TVButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Approve/Decline for managers */}
|
||||||
|
{canManageRequests && pendingRequest && (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.requested_by", { user: requestedByName })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", gap: 16 }}>
|
||||||
|
<TVButton onPress={handleApproveRequest} variant='secondary'>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.approve")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
|
||||||
|
<TVButton onPress={handleDeclineRequest} variant='secondary'>
|
||||||
|
<Ionicons
|
||||||
|
name='close'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.decline")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Cast section */}
|
||||||
|
{cast.length > 0 && jellyseerrApi && (
|
||||||
|
<View style={{ marginTop: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.cast")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Focus guides for bidirectional navigation - stacked together */}
|
||||||
|
{/* Downward: action buttons → first cast card */}
|
||||||
|
{firstCastCardRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[firstCastCardRef]}
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
marginLeft: -(insets.left + 80),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Upward: cast → action buttons */}
|
||||||
|
{playButtonRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[playButtonRef]}
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
marginLeft: -(insets.left + 80),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: 16,
|
||||||
|
gap: 28,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cast.map((person, index) => (
|
||||||
|
<TVCastCard
|
||||||
|
key={person.id}
|
||||||
|
person={person}
|
||||||
|
imageProxy={(path, size) =>
|
||||||
|
jellyseerrApi.imageProxy(path, size || "w185")
|
||||||
|
}
|
||||||
|
onPress={() => handleCastPress(person.id)}
|
||||||
|
refSetter={index === 0 ? setFirstCastCardRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
519
components/jellyseerr/tv/TVRequestModal.tsx
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
BackHandler,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import type {
|
||||||
|
QualityProfile,
|
||||||
|
RootFolder,
|
||||||
|
Tag,
|
||||||
|
} from "@/utils/jellyseerr/server/api/servarr/base";
|
||||||
|
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { TVRequestOptionRow } from "./TVRequestOptionRow";
|
||||||
|
import { TVToggleOptionRow } from "./TVToggleOptionRow";
|
||||||
|
|
||||||
|
interface TVRequestModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
requestBody?: MediaRequestBody;
|
||||||
|
title: string;
|
||||||
|
id: number;
|
||||||
|
mediaType: MediaType;
|
||||||
|
onClose: () => void;
|
||||||
|
onRequested: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
||||||
|
visible,
|
||||||
|
requestBody,
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
mediaType,
|
||||||
|
onClose,
|
||||||
|
onRequested,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
|
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||||
|
mediaId: Number(id),
|
||||||
|
mediaType,
|
||||||
|
userId: jellyseerrUser?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeSelector, setActiveSelector] = useState<
|
||||||
|
"profile" | "folder" | "user" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Handle back button to close modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const handleBackPress = () => {
|
||||||
|
// If a sub-selector is open, close it first
|
||||||
|
if (activeSelector) {
|
||||||
|
setActiveSelector(null);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
return true; // Prevent default back behavior
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = BackHandler.addEventListener(
|
||||||
|
"hardwareBackPress",
|
||||||
|
handleBackPress,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [visible, activeSelector, onClose]);
|
||||||
|
|
||||||
|
const { data: serviceSettings } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "request", mediaType, "service"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.service(mediaType === "movie" ? "radarr" : "sonarr"),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: users } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "users"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultService = useMemo(
|
||||||
|
() => serviceSettings?.find?.((v) => v.isDefault),
|
||||||
|
[serviceSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: defaultServiceDetails } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"jellyseerr",
|
||||||
|
"request",
|
||||||
|
mediaType,
|
||||||
|
"service",
|
||||||
|
"details",
|
||||||
|
defaultService?.id,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serverId: defaultService?.id,
|
||||||
|
}));
|
||||||
|
return jellyseerrApi?.serviceDetails(
|
||||||
|
mediaType === "movie" ? "radarr" : "sonarr",
|
||||||
|
defaultService!.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService && visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProfile: QualityProfile | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.profiles.find(
|
||||||
|
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
|
||||||
|
),
|
||||||
|
[defaultServiceDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultFolder: RootFolder | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.rootFolders.find(
|
||||||
|
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
|
||||||
|
),
|
||||||
|
[defaultServiceDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultTags: Tag[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
defaultServiceDetails?.tags.filter((t) =>
|
||||||
|
defaultServiceDetails?.server.activeTags?.includes(t.id),
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}, [defaultServiceDetails]);
|
||||||
|
|
||||||
|
const pathTitleExtractor = (item: RootFolder) =>
|
||||||
|
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||||
|
|
||||||
|
// Option builders
|
||||||
|
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.profiles.map((profile) => ({
|
||||||
|
label: profile.name,
|
||||||
|
value: profile.id,
|
||||||
|
selected:
|
||||||
|
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
|
||||||
|
})) || [],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
defaultProfile,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||||
|
label: pathTitleExtractor(folder),
|
||||||
|
value: folder.path,
|
||||||
|
selected:
|
||||||
|
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
|
||||||
|
})) || [],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
defaultFolder,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const userOptions: TVOptionItem<number>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
users?.map((user) => ({
|
||||||
|
label: user.displayName,
|
||||||
|
value: user.id,
|
||||||
|
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||||
|
})) || [],
|
||||||
|
[users, jellyseerrUser, requestOverrides.userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagItems = useMemo(() => {
|
||||||
|
return (
|
||||||
|
defaultServiceDetails?.tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
label: tag.label,
|
||||||
|
selected:
|
||||||
|
requestOverrides.tags?.includes(tag.id) ||
|
||||||
|
defaultTags.some((dt) => dt.id === tag.id),
|
||||||
|
})) ?? []
|
||||||
|
);
|
||||||
|
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
|
||||||
|
|
||||||
|
// Selected display values
|
||||||
|
const selectedProfileName = useMemo(() => {
|
||||||
|
const profile = defaultServiceDetails?.profiles.find(
|
||||||
|
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
|
||||||
|
);
|
||||||
|
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
|
||||||
|
}, [
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
defaultProfile,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedFolderName = useMemo(() => {
|
||||||
|
const folder = defaultServiceDetails?.rootFolders.find(
|
||||||
|
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
|
||||||
|
);
|
||||||
|
return folder
|
||||||
|
? pathTitleExtractor(folder)
|
||||||
|
: defaultFolder
|
||||||
|
? pathTitleExtractor(defaultFolder)
|
||||||
|
: t("jellyseerr.select");
|
||||||
|
}, [
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
defaultFolder,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedUserName = useMemo(() => {
|
||||||
|
const user = users?.find(
|
||||||
|
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
|
||||||
|
);
|
||||||
|
}, [users, requestOverrides.userId, jellyseerrUser, t]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleProfileChange = useCallback((profileId: number) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, profileId }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFolderChange = useCallback((rootFolder: string) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUserChange = useCallback((userId: number) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, userId }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTagToggle = useCallback(
|
||||||
|
(tagId: number) => {
|
||||||
|
setRequestOverrides((prev) => {
|
||||||
|
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||||
|
const hasTag = currentTags.includes(tagId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tags: hasTag
|
||||||
|
? currentTags.filter((id) => id !== tagId)
|
||||||
|
: [...currentTags, tagId],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defaultTags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRequest = useCallback(() => {
|
||||||
|
const body = {
|
||||||
|
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||||
|
profileId: defaultProfile?.id,
|
||||||
|
rootFolder: defaultFolder?.path,
|
||||||
|
tags: defaultTags.map((t) => t.id),
|
||||||
|
...requestBody,
|
||||||
|
...requestOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const seasonTitle =
|
||||||
|
requestBody?.seasons?.length === 1
|
||||||
|
? t("jellyseerr.season_number", {
|
||||||
|
season_number: requestBody.seasons[0],
|
||||||
|
})
|
||||||
|
: requestBody?.seasons && requestBody.seasons.length > 1
|
||||||
|
? t("jellyseerr.season_all")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
requestMedia(
|
||||||
|
seasonTitle ? `${title}, ${seasonTitle}` : title,
|
||||||
|
body,
|
||||||
|
onRequested,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
requestBody,
|
||||||
|
requestOverrides,
|
||||||
|
defaultProfile,
|
||||||
|
defaultFolder,
|
||||||
|
defaultTags,
|
||||||
|
defaultService,
|
||||||
|
defaultServiceDetails,
|
||||||
|
title,
|
||||||
|
requestMedia,
|
||||||
|
onRequested,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const isDataLoaded = defaultService && defaultServiceDetails && users;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
opacity: overlayOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
transform: [{ translateY: sheetTranslateY }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 44,
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.advanced")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isDataLoaded ? (
|
||||||
|
<ScrollView
|
||||||
|
style={{ maxHeight: 320, overflow: "visible" }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.quality_profile")}
|
||||||
|
value={selectedProfileName}
|
||||||
|
onPress={() => setActiveSelector("profile")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.root_folder")}
|
||||||
|
value={selectedFolderName}
|
||||||
|
onPress={() => setActiveSelector("folder")}
|
||||||
|
/>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.request_as")}
|
||||||
|
value={selectedUserName}
|
||||||
|
onPress={() => setActiveSelector("user")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tagItems.length > 0 && (
|
||||||
|
<TVToggleOptionRow
|
||||||
|
label={t("jellyseerr.tags")}
|
||||||
|
items={tagItems}
|
||||||
|
onToggle={handleTagToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 200,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "rgba(255,255,255,0.5)" }}>
|
||||||
|
{t("common.loading")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ marginTop: 24 }}>
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequest}
|
||||||
|
variant='secondary'
|
||||||
|
disabled={!isDataLoaded}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_button")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Sub-selectors */}
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "profile"}
|
||||||
|
title={t("jellyseerr.quality_profile")}
|
||||||
|
options={qualityProfileOptions}
|
||||||
|
onSelect={handleProfileChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "folder"}
|
||||||
|
title={t("jellyseerr.root_folder")}
|
||||||
|
options={rootFolderOptions}
|
||||||
|
onSelect={handleFolderChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
cardWidth={280}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "user"}
|
||||||
|
title={t("jellyseerr.request_as")}
|
||||||
|
options={userOptions}
|
||||||
|
onSelect={handleUserChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
86
components/jellyseerr/tv/TVRequestOptionRow.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
interface TVRequestOptionRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.02,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(255,255,255,0.15)"
|
||||||
|
: "rgba(255,255,255,0.05)",
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: focused
|
||||||
|
? "rgba(255,255,255,0.3)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-forward'
|
||||||
|
size={18}
|
||||||
|
color={focused ? "#FFFFFF" : "rgba(255,255,255,0.5)"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
117
components/jellyseerr/tv/TVToggleOptionRow.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
interface ToggleItem {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TVToggleChipProps {
|
||||||
|
item: ToggleItem;
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
||||||
|
item,
|
||||||
|
onToggle,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.08,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onToggle(item.id)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: item.selected
|
||||||
|
? "rgba(255,255,255,0.25)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: item.selected
|
||||||
|
? "rgba(255,255,255,0.4)"
|
||||||
|
: "rgba(255,255,255,0.15)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#000" : "#fff",
|
||||||
|
fontWeight: item.selected || focused ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVToggleOptionRowProps {
|
||||||
|
label: string;
|
||||||
|
items: ToggleItem[];
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
onToggle,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{ gap: 10, paddingVertical: 12 }}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TVToggleChip
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onToggle={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
components/jellyseerr/tv/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { TVJellyseerrPage } from "./TVJellyseerrPage";
|
||||||
|
export { TVRequestModal } from "./TVRequestModal";
|
||||||
|
export { TVRequestOptionRow } from "./TVRequestOptionRow";
|
||||||
|
export { TVToggleOptionRow } from "./TVToggleOptionRow";
|
||||||
109
components/library/Libraries.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, StyleSheet, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export const Libraries: React.FC = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
staleTime: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const libraries = useMemo(
|
||||||
|
() =>
|
||||||
|
data
|
||||||
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
|
[data, settings?.hiddenLibraries],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const item of data || []) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["library", item.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!item.Id || !user?.Id || !api) return null;
|
||||||
|
const response = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: item.Id,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, api, queryClient, user?.Id]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!libraries)
|
||||||
|
return (
|
||||||
|
<View className='h-full w-full flex justify-center items-center'>
|
||||||
|
<Text className='text-lg text-neutral-500'>
|
||||||
|
{t("library.no_libraries_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
extraData={settings}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||||
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
paddingLeft: insets.left + 17,
|
||||||
|
paddingRight: insets.right + 17,
|
||||||
|
}}
|
||||||
|
data={libraries}
|
||||||
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
ItemSeparatorComponent={() =>
|
||||||
|
settings?.libraryOptions?.display === "row" ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
}}
|
||||||
|
className='bg-neutral-800 mx-2 my-4'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='h-4' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
395
components/library/TVLibraries.tsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
CollectionType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, Easing, FlatList, Pressable, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 80;
|
||||||
|
const CARD_HEIGHT = 220;
|
||||||
|
const CARD_GAP = 24;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
const icons: Record<CollectionType, IconName> = {
|
||||||
|
movies: "film",
|
||||||
|
tvshows: "tv",
|
||||||
|
music: "musical-notes",
|
||||||
|
books: "book",
|
||||||
|
homevideos: "videocam",
|
||||||
|
boxsets: "albums",
|
||||||
|
playlists: "list",
|
||||||
|
folders: "folder",
|
||||||
|
livetv: "tv",
|
||||||
|
musicvideos: "musical-notes",
|
||||||
|
photos: "images",
|
||||||
|
trailers: "videocam",
|
||||||
|
unknown: "help-circle",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface LibraryWithPreview extends BaseItemDto {
|
||||||
|
previewItems?: BaseItemDto[];
|
||||||
|
itemCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVLibraryRow: React.FC<{
|
||||||
|
library: LibraryWithPreview;
|
||||||
|
isFirst: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}> = ({ library, isFirst, onPress }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const opacity = useRef(new Animated.Value(0.7)).current;
|
||||||
|
|
||||||
|
const animateTo = (toScale: number, toOpacity: number) => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: toScale,
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: toOpacity,
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
// Try to get backdrop from library or first preview item
|
||||||
|
if (library.previewItems?.[0]) {
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: library.previewItems[0],
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: library,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, library]);
|
||||||
|
|
||||||
|
const iconName = icons[library.CollectionType!] || "folder";
|
||||||
|
|
||||||
|
const itemTypeName = useMemo(() => {
|
||||||
|
if (library.CollectionType === "movies")
|
||||||
|
return t("library.item_types.movies");
|
||||||
|
if (library.CollectionType === "tvshows")
|
||||||
|
return t("library.item_types.series");
|
||||||
|
if (library.CollectionType === "boxsets")
|
||||||
|
return t("library.item_types.boxsets");
|
||||||
|
if (library.CollectionType === "music")
|
||||||
|
return t("library.item_types.items");
|
||||||
|
return t("library.item_types.items");
|
||||||
|
}, [library.CollectionType, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.02, 1);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1, 0.7);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
opacity,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderWidth: focused ? 4 : 0,
|
||||||
|
borderColor: "#FFFFFF",
|
||||||
|
shadowColor: "#FFFFFF",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.3 : 0,
|
||||||
|
shadowRadius: focused ? 30 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background Image */}
|
||||||
|
{backdropUrl && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: backdropUrl }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gradient Overlay */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.8)"]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon Container */}
|
||||||
|
<BlurView
|
||||||
|
intensity={60}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={iconName} size={40} color='#FFFFFF' />
|
||||||
|
</BlurView>
|
||||||
|
|
||||||
|
{/* Text Content */}
|
||||||
|
<View style={{ marginLeft: 24, flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
|
textShadowOffset: { width: 0, height: 2 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{library.Name}
|
||||||
|
</Text>
|
||||||
|
{library.itemCount !== undefined && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
marginTop: 4,
|
||||||
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{library.itemCount} {itemTypeName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Arrow Indicator */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
opacity: focused ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='chevron-forward' size={32} color='#FFFFFF' />
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVLibraries: React.FC = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
const { data: userViews, isLoading: viewsLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const libraries = useMemo(
|
||||||
|
() =>
|
||||||
|
userViews
|
||||||
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
|
.filter((l) => l.CollectionType !== "books")
|
||||||
|
.filter((l) => l.CollectionType !== "music")
|
||||||
|
.filter((l) => l.CollectionType !== "playlists") || [],
|
||||||
|
[userViews, settings?.hiddenLibraries],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch item counts and preview items for each library
|
||||||
|
const { data: librariesWithData, isLoading: dataLoading } = useQuery({
|
||||||
|
queryKey: ["library-data", libraries.map((l) => l.Id).join(",")],
|
||||||
|
queryFn: async () => {
|
||||||
|
const results: LibraryWithPreview[] = await Promise.all(
|
||||||
|
libraries.map(async (library) => {
|
||||||
|
let itemType: string | undefined;
|
||||||
|
if (library.CollectionType === "movies") itemType = "Movie";
|
||||||
|
else if (library.CollectionType === "tvshows") itemType = "Series";
|
||||||
|
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
|
||||||
|
|
||||||
|
// Fetch count
|
||||||
|
const countResponse = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: library.Id,
|
||||||
|
recursive: true,
|
||||||
|
limit: 0,
|
||||||
|
includeItemTypes: itemType ? [itemType as any] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch preview items with backdrops
|
||||||
|
const previewResponse = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: library.Id,
|
||||||
|
recursive: true,
|
||||||
|
limit: 1,
|
||||||
|
sortBy: ["Random"],
|
||||||
|
includeItemTypes: itemType ? [itemType as any] : undefined,
|
||||||
|
imageTypes: ["Backdrop"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...library,
|
||||||
|
itemCount: countResponse.data.TotalRecordCount,
|
||||||
|
previewItems: previewResponse.data.Items || [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && libraries.length > 0,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLibraryPress = useCallback(
|
||||||
|
(library: BaseItemDto) => {
|
||||||
|
if (library.CollectionType === "music") {
|
||||||
|
router.push({
|
||||||
|
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
|
||||||
|
params: { libraryId: library.Id! },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||||
|
params: { libraryId: library.Id! },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: LibraryWithPreview; index: number }) => (
|
||||||
|
<View style={{ marginBottom: CARD_GAP, paddingHorizontal: 8 }}>
|
||||||
|
<TVLibraryRow
|
||||||
|
library={item}
|
||||||
|
isFirst={index === 0}
|
||||||
|
onPress={() => handleLibraryPress(item)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[handleLibraryPress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = viewsLoading || dataLoading;
|
||||||
|
const displayLibraries = librariesWithData || libraries;
|
||||||
|
|
||||||
|
if (isLoading && libraries.length === 0) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!displayLibraries || displayLibraries.length === 0) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||||
|
{t("library.no_libraries_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: insets.top + 80,
|
||||||
|
paddingBottom: insets.bottom + 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlatList
|
||||||
|
data={displayLibraries}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
renderItem={renderItem}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 40,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
components/library/TVLibraryCard.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
CollectionType,
|
||||||
|
} 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 { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
export const TV_LIBRARY_CARD_WIDTH = 280;
|
||||||
|
export const TV_LIBRARY_CARD_HEIGHT = 180;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
library: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
const icons: Record<CollectionType, IconName> = {
|
||||||
|
movies: "film",
|
||||||
|
tvshows: "tv",
|
||||||
|
music: "musical-notes",
|
||||||
|
books: "book",
|
||||||
|
homevideos: "videocam",
|
||||||
|
boxsets: "albums",
|
||||||
|
playlists: "list",
|
||||||
|
folders: "folder",
|
||||||
|
livetv: "tv",
|
||||||
|
musicvideos: "musical-notes",
|
||||||
|
photos: "images",
|
||||||
|
trailers: "videocam",
|
||||||
|
unknown: "help-circle",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() =>
|
||||||
|
getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item: library,
|
||||||
|
}),
|
||||||
|
[api, library],
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemType = useMemo(() => {
|
||||||
|
let _itemType: BaseItemKind | undefined;
|
||||||
|
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
_itemType = "Movie";
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
_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;
|
||||||
|
}, [library.CollectionType]);
|
||||||
|
|
||||||
|
const itemTypeName = useMemo(() => {
|
||||||
|
let nameStr: string;
|
||||||
|
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
nameStr = t("library.item_types.movies");
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
nameStr = t("library.item_types.series");
|
||||||
|
} else if (library.CollectionType === "boxsets") {
|
||||||
|
nameStr = t("library.item_types.boxsets");
|
||||||
|
} else {
|
||||||
|
nameStr = t("library.item_types.items");
|
||||||
|
}
|
||||||
|
|
||||||
|
return nameStr;
|
||||||
|
}, [library.CollectionType, t]);
|
||||||
|
|
||||||
|
const { data: itemsCount } = useQuery({
|
||||||
|
queryKey: ["library-count", library.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: library.Id,
|
||||||
|
recursive: true,
|
||||||
|
limit: 0,
|
||||||
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
|
});
|
||||||
|
return response.data.TotalRecordCount;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!library.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = icons[library.CollectionType!] || "folder";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_LIBRARY_CARD_WIDTH,
|
||||||
|
height: TV_LIBRARY_CARD_HEIGHT,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: url ? "rgba(0, 0, 0, 0.6)" : "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={iconName} size={48} color='#e5e5e5' />
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginTop: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{library.Name}
|
||||||
|
</Text>
|
||||||
|
{itemsCount !== undefined && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{itemsCount} {itemTypeName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
456
components/login/Login.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Switch,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
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")),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Login: React.FC = () => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const {
|
||||||
|
setServer,
|
||||||
|
login,
|
||||||
|
removeServer,
|
||||||
|
initiateQuickConnect,
|
||||||
|
loginWithSavedCredential,
|
||||||
|
loginWithPassword,
|
||||||
|
} = useJellyfin();
|
||||||
|
|
||||||
|
const {
|
||||||
|
apiUrl: _apiUrl,
|
||||||
|
username: _username,
|
||||||
|
password: _password,
|
||||||
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
||||||
|
const [serverName, setServerName] = useState<string>("");
|
||||||
|
const [credentials, setCredentials] = useState<{
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}>({
|
||||||
|
username: _username || "",
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (_apiUrl) {
|
||||||
|
await setServer({
|
||||||
|
address: _apiUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_username && _password) {
|
||||||
|
setCredentials({ username: _username, password: _password });
|
||||||
|
login(_username, _password);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerTitle: serverName,
|
||||||
|
headerLeft: () =>
|
||||||
|
api?.basePath ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
removeServer();
|
||||||
|
}}
|
||||||
|
className='flex flex-row items-center pr-2 pl-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||||
|
<Text className=' ml-1 text-purple-600'>
|
||||||
|
{t("login.change_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null,
|
||||||
|
});
|
||||||
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
|
if (!result.success) return;
|
||||||
|
|
||||||
|
if (saveAccount) {
|
||||||
|
setPendingLogin({
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
});
|
||||||
|
setShowSaveModal(true);
|
||||||
|
} else {
|
||||||
|
await performLogin(credentials.username, credentials.password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const performLogin = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username, password, serverName, options);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.an_unexpected_error_occured"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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) => {
|
||||||
|
setServer({ address: server.address });
|
||||||
|
if (server.name) {
|
||||||
|
setServerName(server.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkUrl = useCallback(async (url: string) => {
|
||||||
|
setLoadingServerCheck(true);
|
||||||
|
const baseUrl = url.replace(/^https?:\/\//i, "");
|
||||||
|
const protocols = ["https", "http"];
|
||||||
|
try {
|
||||||
|
return checkHttp(baseUrl, protocols);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "Server too old") {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
setLoadingServerCheck(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function checkHttp(baseUrl: string, protocols: string[]) {
|
||||||
|
for (const protocol of protocols) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${protocol}://${baseUrl}/System/Info/Public`,
|
||||||
|
{
|
||||||
|
mode: "cors",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
|
const serverVersion = data.Version?.split(".");
|
||||||
|
if (serverVersion && +serverVersion[0] <= 10) {
|
||||||
|
if (+serverVersion[1] < 10) {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.too_old_server_text"),
|
||||||
|
t("login.too_old_server_description"),
|
||||||
|
);
|
||||||
|
throw new Error("Server too old");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setServerName(data.ServerName || "");
|
||||||
|
return `${protocol}://${baseUrl}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "Server too old") {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConnect = useCallback(async (url: string) => {
|
||||||
|
url = url.trim().replace(/\/$/, "");
|
||||||
|
try {
|
||||||
|
const result = await checkUrl(url);
|
||||||
|
if (result === undefined) {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.could_not_connect_to_server"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setServer({ address: result });
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleQuickConnect = async () => {
|
||||||
|
try {
|
||||||
|
const code = await initiateQuickConnect();
|
||||||
|
if (code) {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.quick_connect"),
|
||||||
|
t("login.enter_code_to_login", { code: code }),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("login.got_it"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.error_title"),
|
||||||
|
t("login.failed_to_initiate_quick_connect"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{api?.basePath ? (
|
||||||
|
<View className='flex flex-col flex-1 justify-center'>
|
||||||
|
<View className='px-4 w-full'>
|
||||||
|
<View className='flex flex-col space-y-2'>
|
||||||
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
|
{serverName ? (
|
||||||
|
<>
|
||||||
|
{`${t("login.login_to_title")} `}
|
||||||
|
<Text className='text-purple-600'>{serverName}</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("login.login_title")
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
||||||
|
<Input
|
||||||
|
placeholder={t("login.username_placeholder")}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials((prev) => ({ ...prev, username: text }))
|
||||||
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.username) {
|
||||||
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
username: newValue,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={credentials.username}
|
||||||
|
keyboardType='default'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
textContentType='username'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder={t("login.password_placeholder")}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials((prev) => ({ ...prev, password: text }))
|
||||||
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.password) {
|
||||||
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: newValue,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={credentials.password}
|
||||||
|
secureTextEntry
|
||||||
|
keyboardType='default'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='password'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setSaveAccount(!saveAccount)}
|
||||||
|
className='flex flex-row items-center py-2'
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={saveAccount}
|
||||||
|
onValueChange={setSaveAccount}
|
||||||
|
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||||
|
thumbColor='white'
|
||||||
|
/>
|
||||||
|
<Text className='ml-3 text-neutral-300'>
|
||||||
|
{t("save_account.save_for_later")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View className='flex flex-row items-center justify-between'>
|
||||||
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!credentials.username.trim()}
|
||||||
|
className='flex-1 mr-2'
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleQuickConnect}
|
||||||
|
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='cellphone-lock'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||||
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
marginLeft: -23,
|
||||||
|
marginBottom: -20,
|
||||||
|
}}
|
||||||
|
source={require("@/assets/images/icon-ios-plain.png")}
|
||||||
|
/>
|
||||||
|
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
aria-label='Server URL'
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
value={serverURL}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck}
|
||||||
|
onPress={async () => {
|
||||||
|
await handleConnect(serverURL);
|
||||||
|
}}
|
||||||
|
className='w-full grow'
|
||||||
|
>
|
||||||
|
{t("server.connect_button")}
|
||||||
|
</Button>
|
||||||
|
<JellyfinServerDiscovery
|
||||||
|
onServerSelect={async (server) => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
await handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={async (s) => {
|
||||||
|
await handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||||
|
onPasswordLogin={handlePasswordLogin}
|
||||||
|
onAddAccount={handleAddAccount}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={showSaveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
151
components/login/TVAccountCard.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, Easing, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import type { SavedServerAccount } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
interface TVAccountCardProps {
|
||||||
|
account: SavedServerAccount;
|
||||||
|
onPress: () => void;
|
||||||
|
onLongPress?: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVAccountCard: React.FC<TVAccountCardProps> = ({
|
||||||
|
account,
|
||||||
|
onPress,
|
||||||
|
onLongPress,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const glowOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.03 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(glowOpacity, {
|
||||||
|
toValue: focused ? 0.6 : 0,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
|
||||||
|
switch (account.securityType) {
|
||||||
|
case "pin":
|
||||||
|
return "keypad";
|
||||||
|
case "password":
|
||||||
|
return "lock-closed";
|
||||||
|
default:
|
||||||
|
return "key";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecurityText = (): string => {
|
||||||
|
switch (account.securityType) {
|
||||||
|
case "pin":
|
||||||
|
return t("save_account.pin_code");
|
||||||
|
case "password":
|
||||||
|
return t("save_account.password");
|
||||||
|
default:
|
||||||
|
return t("save_account.no_protection");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#a855f7",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
{ shadowOpacity: glowOpacity },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: isFocused ? "#2a2a2a" : "#262626",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
backgroundColor: "#404040",
|
||||||
|
borderRadius: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginRight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='person' size={28} color='white' />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Account Info */}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{account.username}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getSecurityText()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Security Icon */}
|
||||||
|
<Ionicons name={getSecurityIcon()} size={24} color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
components/login/TVInput.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
type TextInputProps,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
interface TVInputProps extends TextInputProps {
|
||||||
|
label?: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVInput: React.FC<TVInputProps> = ({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.02 : 1,
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayPlaceholder = placeholder || label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => inputRef.current?.focus()}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={displayPlaceholder}
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
height: 68,
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
721
components/login/TVLogin.tsx
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVInput } from "@/components/login/TVInput";
|
||||||
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
|
import {
|
||||||
|
TVPreviousServersList,
|
||||||
|
TVServerActionSheet,
|
||||||
|
} from "@/components/login/TVPreviousServersList";
|
||||||
|
import { TVSaveAccountModal } from "@/components/login/TVSaveAccountModal";
|
||||||
|
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
type AccountSecurityType,
|
||||||
|
removeServerFromList,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
const CredentialsSchema = z.object({
|
||||||
|
username: z.string().min(1, t("login.username_required")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TVBackButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, label, disabled = false }) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.05 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
}}
|
||||||
|
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: isFocused
|
||||||
|
? "rgba(168, 85, 247, 0.2)"
|
||||||
|
: "transparent",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? Colors.primary : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-back'
|
||||||
|
size={28}
|
||||||
|
color={isFocused ? "#FFFFFF" : Colors.primary}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: isFocused ? "#FFFFFF" : Colors.primary,
|
||||||
|
fontSize: 20,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVLogin: React.FC = () => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const {
|
||||||
|
setServer,
|
||||||
|
login,
|
||||||
|
removeServer,
|
||||||
|
initiateQuickConnect,
|
||||||
|
loginWithSavedCredential,
|
||||||
|
loginWithPassword,
|
||||||
|
} = useJellyfin();
|
||||||
|
|
||||||
|
const {
|
||||||
|
apiUrl: _apiUrl,
|
||||||
|
username: _username,
|
||||||
|
password: _password,
|
||||||
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
||||||
|
const [serverName, setServerName] = useState<string>("");
|
||||||
|
const [credentials, setCredentials] = useState<{
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}>({
|
||||||
|
username: _username || "",
|
||||||
|
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);
|
||||||
|
|
||||||
|
// PIN/Password entry for saved accounts
|
||||||
|
const [pinModalVisible, setPinModalVisible] = useState(false);
|
||||||
|
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
||||||
|
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [selectedAccount, setSelectedAccount] =
|
||||||
|
useState<SavedServerAccount | null>(null);
|
||||||
|
|
||||||
|
// Server action sheet state
|
||||||
|
const [showServerActionSheet, setShowServerActionSheet] = useState(false);
|
||||||
|
const [actionSheetServer, setActionSheetServer] =
|
||||||
|
useState<SavedServer | null>(null);
|
||||||
|
const [loginTriggerServer, setLoginTriggerServer] =
|
||||||
|
useState<SavedServer | null>(null);
|
||||||
|
const [actionSheetKey, setActionSheetKey] = useState(0);
|
||||||
|
|
||||||
|
// Track if any modal is open to disable background focus
|
||||||
|
const isAnyModalOpen =
|
||||||
|
showSaveModal ||
|
||||||
|
pinModalVisible ||
|
||||||
|
passwordModalVisible ||
|
||||||
|
showServerActionSheet;
|
||||||
|
|
||||||
|
// Auto login from URL params
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (_apiUrl) {
|
||||||
|
await setServer({ address: _apiUrl });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_username && _password) {
|
||||||
|
setCredentials({ username: _username, password: _password });
|
||||||
|
login(_username, _password);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
|
// Update header
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerTitle: serverName,
|
||||||
|
headerShown: false,
|
||||||
|
});
|
||||||
|
}, [serverName, navigation]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
|
if (!result.success) return;
|
||||||
|
|
||||||
|
if (saveAccount) {
|
||||||
|
setPendingLogin({
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
});
|
||||||
|
setShowSaveModal(true);
|
||||||
|
} else {
|
||||||
|
await performLogin(credentials.username, credentials.password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const performLogin = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username, password, serverName, options);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.an_unexpected_error_occured"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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) => {
|
||||||
|
setServer({ address: server.address });
|
||||||
|
if (server.name) {
|
||||||
|
setServerName(server.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinRequired = (
|
||||||
|
server: SavedServer,
|
||||||
|
account: SavedServerAccount,
|
||||||
|
) => {
|
||||||
|
setSelectedServer(server);
|
||||||
|
setSelectedAccount(account);
|
||||||
|
setPinModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordRequired = (
|
||||||
|
server: SavedServer,
|
||||||
|
account: SavedServerAccount,
|
||||||
|
) => {
|
||||||
|
setSelectedServer(server);
|
||||||
|
setSelectedAccount(account);
|
||||||
|
setPasswordModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinSuccess = async () => {
|
||||||
|
setPinModalVisible(false);
|
||||||
|
if (selectedServer && selectedAccount) {
|
||||||
|
await handleQuickLoginWithSavedCredential(
|
||||||
|
selectedServer.address,
|
||||||
|
selectedAccount.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setSelectedServer(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordSubmit = async (password: string) => {
|
||||||
|
if (selectedServer && selectedAccount) {
|
||||||
|
await handlePasswordLogin(
|
||||||
|
selectedServer.address,
|
||||||
|
selectedAccount.username,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
setSelectedServer(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForgotPIN = async () => {
|
||||||
|
if (selectedServer) {
|
||||||
|
setSelectedServer(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setPinModalVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server action sheet handlers
|
||||||
|
const handleServerAction = (server: SavedServer) => {
|
||||||
|
setActionSheetServer(server);
|
||||||
|
setActionSheetKey((k) => k + 1); // Force remount to reset focus
|
||||||
|
setShowServerActionSheet(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerActionLogin = () => {
|
||||||
|
setShowServerActionSheet(false);
|
||||||
|
if (actionSheetServer) {
|
||||||
|
// Trigger the login flow in TVPreviousServersList
|
||||||
|
setLoginTriggerServer(actionSheetServer);
|
||||||
|
// Reset the trigger after a tick to allow re-triggering the same server
|
||||||
|
setTimeout(() => setLoginTriggerServer(null), 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerActionDelete = () => {
|
||||||
|
if (!actionSheetServer) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("server.remove_server"),
|
||||||
|
t("server.remove_server_description", {
|
||||||
|
server: actionSheetServer.name || actionSheetServer.address,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("common.cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
onPress: () => setShowServerActionSheet(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.delete"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await removeServerFromList(actionSheetServer.address);
|
||||||
|
setShowServerActionSheet(false);
|
||||||
|
setActionSheetServer(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkUrl = useCallback(async (url: string) => {
|
||||||
|
setLoadingServerCheck(true);
|
||||||
|
const baseUrl = url.replace(/^https?:\/\//i, "");
|
||||||
|
const protocols = ["https", "http"];
|
||||||
|
try {
|
||||||
|
return checkHttp(baseUrl, protocols);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "Server too old") {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
setLoadingServerCheck(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function checkHttp(baseUrl: string, protocols: string[]) {
|
||||||
|
for (const protocol of protocols) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${protocol}://${baseUrl}/System/Info/Public`,
|
||||||
|
{ mode: "cors" },
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
|
const serverVersion = data.Version?.split(".");
|
||||||
|
if (serverVersion && +serverVersion[0] <= 10) {
|
||||||
|
if (+serverVersion[1] < 10) {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.too_old_server_text"),
|
||||||
|
t("login.too_old_server_description"),
|
||||||
|
);
|
||||||
|
throw new Error("Server too old");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setServerName(data.ServerName || "");
|
||||||
|
return `${protocol}://${baseUrl}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "Server too old") {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConnect = useCallback(async (url: string) => {
|
||||||
|
url = url.trim().replace(/\/$/, "");
|
||||||
|
console.log("[TVLogin] handleConnect called with:", url);
|
||||||
|
try {
|
||||||
|
const result = await checkUrl(url);
|
||||||
|
console.log("[TVLogin] checkUrl result:", result);
|
||||||
|
if (result === undefined) {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.could_not_connect_to_server"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("[TVLogin] Calling setServer with:", result);
|
||||||
|
await setServer({ address: result });
|
||||||
|
console.log("[TVLogin] setServer completed successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[TVLogin] Error in handleConnect:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleQuickConnect = async () => {
|
||||||
|
try {
|
||||||
|
const code = await initiateQuickConnect();
|
||||||
|
if (code) {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.quick_connect"),
|
||||||
|
t("login.enter_code_to_login", { code: code }),
|
||||||
|
[{ text: t("login.got_it") }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.error_title"),
|
||||||
|
t("login.failed_to_initiate_quick_connect"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log("[TVLogin] Render - api?.basePath:", api?.basePath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{api?.basePath ? (
|
||||||
|
// ==================== CREDENTIALS SCREEN ====================
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 60,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
paddingHorizontal: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Back Button */}
|
||||||
|
<TVBackButton
|
||||||
|
onPress={() => removeServer()}
|
||||||
|
label={t("login.change_server")}
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serverName ? (
|
||||||
|
<>
|
||||||
|
{`${t("login.login_to_title")} `}
|
||||||
|
<Text style={{ color: Colors.primary }}>{serverName}</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("login.login_title")
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{api.basePath}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Username Input - extra padding for focus scale */}
|
||||||
|
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||||
|
<TVInput
|
||||||
|
placeholder={t("login.username_placeholder")}
|
||||||
|
value={credentials.username}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials((prev) => ({ ...prev, username: text }))
|
||||||
|
}
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
textContentType='username'
|
||||||
|
returnKeyType='next'
|
||||||
|
hasTVPreferredFocus
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
|
||||||
|
<TVInput
|
||||||
|
placeholder={t("login.password_placeholder")}
|
||||||
|
value={credentials.password}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials((prev) => ({ ...prev, password: text }))
|
||||||
|
}
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='password'
|
||||||
|
returnKeyType='done'
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Account Toggle */}
|
||||||
|
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
||||||
|
<TVSaveAccountToggle
|
||||||
|
value={saveAccount}
|
||||||
|
onValueChange={setSaveAccount}
|
||||||
|
label={t("save_account.save_for_later")}
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Login Button */}
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!credentials.username.trim() || loading}
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quick Connect Button */}
|
||||||
|
<Button
|
||||||
|
onPress={handleQuickConnect}
|
||||||
|
color='black'
|
||||||
|
className='bg-neutral-800 border border-neutral-700'
|
||||||
|
>
|
||||||
|
{t("login.quick_connect")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
// ==================== SERVER SELECTION SCREEN ====================
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 60,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
paddingHorizontal: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<View style={{ alignItems: "center", marginBottom: 16 }}>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/images/icon-tvos.png")}
|
||||||
|
style={{ width: 150, height: 150 }}
|
||||||
|
contentFit='contain'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Streamyfin
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Server URL Input - extra padding for focus scale */}
|
||||||
|
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||||
|
<TVInput
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
value={serverURL}
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
keyboardType='url'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
returnKeyType='done'
|
||||||
|
hasTVPreferredFocus
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Connect Button */}
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
onPress={() => handleConnect(serverURL)}
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck || !serverURL.trim()}
|
||||||
|
>
|
||||||
|
{t("server.connect_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Previous Servers */}
|
||||||
|
<View style={{ paddingHorizontal: 8 }}>
|
||||||
|
<TVPreviousServersList
|
||||||
|
onServerSelect={(s) => handleConnect(s.address)}
|
||||||
|
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||||
|
onPasswordLogin={handlePasswordLogin}
|
||||||
|
onAddAccount={handleAddAccount}
|
||||||
|
onPinRequired={handlePinRequired}
|
||||||
|
onPasswordRequired={handlePasswordRequired}
|
||||||
|
onServerAction={handleServerAction}
|
||||||
|
loginServerOverride={loginTriggerServer}
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Account Modal */}
|
||||||
|
<TVSaveAccountModal
|
||||||
|
visible={showSaveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* PIN Entry Modal */}
|
||||||
|
<TVPINEntryModal
|
||||||
|
visible={pinModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setPinModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}}
|
||||||
|
onSuccess={handlePinSuccess}
|
||||||
|
onForgotPIN={handleForgotPIN}
|
||||||
|
serverUrl={selectedServer?.address || ""}
|
||||||
|
userId={selectedAccount?.userId || ""}
|
||||||
|
username={selectedAccount?.username || ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Entry Modal */}
|
||||||
|
<TVPasswordEntryModal
|
||||||
|
visible={passwordModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handlePasswordSubmit}
|
||||||
|
username={selectedAccount?.username || ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Server Action Sheet */}
|
||||||
|
<TVServerActionSheet
|
||||||
|
key={actionSheetKey}
|
||||||
|
visible={showServerActionSheet}
|
||||||
|
server={actionSheetServer}
|
||||||
|
onLogin={handleServerActionLogin}
|
||||||
|
onDelete={handleServerActionDelete}
|
||||||
|
onClose={() => setShowServerActionSheet(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
327
components/login/TVPINEntryModal.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv";
|
||||||
|
import { verifyAccountPIN } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
interface TVPINEntryModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onForgotPIN?: () => void;
|
||||||
|
serverUrl: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forgot PIN Button
|
||||||
|
const TVForgotPINButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(168, 85, 247, 0.2)"
|
||||||
|
: "transparent",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#d8b4fe" : "#a855f7",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onForgotPIN,
|
||||||
|
serverUrl,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [pinCode, setPinCode] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
const pinInputRef = useRef<TVPinInputRef>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// Reset state when opening
|
||||||
|
setPinCode("");
|
||||||
|
setError(null);
|
||||||
|
setIsVerifying(false);
|
||||||
|
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && isReady) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pinInputRef.current?.focus();
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible, isReady]);
|
||||||
|
|
||||||
|
const shake = () => {
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 15,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: -15,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 15,
|
||||||
|
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) {
|
||||||
|
onSuccess();
|
||||||
|
setPinCode("");
|
||||||
|
} else {
|
||||||
|
setError(t("pin.invalid_pin"));
|
||||||
|
shake();
|
||||||
|
setPinCode("");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
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?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{t("pin.enter_pin_for", { username })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* PIN Input */}
|
||||||
|
{isReady && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.pinContainer,
|
||||||
|
{ transform: [{ translateX: shakeAnimation }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TVPinInput
|
||||||
|
ref={pinInputRef}
|
||||||
|
value={pinCode}
|
||||||
|
onChangeText={handlePinChange}
|
||||||
|
length={4}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
{isVerifying && (
|
||||||
|
<Text style={styles.verifyingText}>
|
||||||
|
{t("common.verifying")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Forgot PIN */}
|
||||||
|
{isReady && onForgotPIN && (
|
||||||
|
<View style={styles.forgotContainer}>
|
||||||
|
<TVForgotPINButton
|
||||||
|
onPress={handleForgotPIN}
|
||||||
|
label={t("pin.forgot_pin")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
pinContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
verifyingText: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
forgotContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
337
components/login/TVPasswordEntryModal.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv";
|
||||||
|
|
||||||
|
interface TVPasswordEntryModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (password: string) => Promise<void>;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV Submit Button
|
||||||
|
const TVSubmitButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, label, loading = false, disabled = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={isDisabled}
|
||||||
|
focusable={!isDisabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#a855f7"
|
||||||
|
: isDisabled
|
||||||
|
? "#4a4a4a"
|
||||||
|
: "#7c3aed",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
minWidth: 120,
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size='small' color='#fff' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name='log-in-outline' size={20} color='#fff' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TV Focusable Password Input
|
||||||
|
const TVPasswordInput: React.FC<{
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
onSubmitEditing: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
onSubmitEditing,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => inputRef.current?.focus()}
|
||||||
|
onFocus={() => {
|
||||||
|
handleFocus();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: "#1F2937",
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#6366F1" : "#374151",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 18,
|
||||||
|
}}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
returnKeyType='done'
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// Reset state when opening
|
||||||
|
setPassword("");
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!password) {
|
||||||
|
setError(t("password.enter_password"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(password);
|
||||||
|
setPassword("");
|
||||||
|
} catch {
|
||||||
|
setError(t("password.invalid_password"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("password.enter_password")}</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{t("password.enter_password_for", { username })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>{t("login.password")}</Text>
|
||||||
|
<TVPasswordInput
|
||||||
|
value={password}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPassword(text);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder={t("login.password")}
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TVSubmitButton
|
||||||
|
onPress={handleSubmit}
|
||||||
|
label={t("login.login")}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={!password}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
});
|
||||||
512
components/login/TVPreviousServersList.tsx
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useMMKVString } from "react-native-mmkv";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import {
|
||||||
|
deleteAccountCredential,
|
||||||
|
getPreviousServers,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
import { TVAccountCard } from "./TVAccountCard";
|
||||||
|
import { TVServerCard } from "./TVServerCard";
|
||||||
|
|
||||||
|
// Action card for server action sheet (Apple TV style)
|
||||||
|
const TVServerActionCard: React.FC<{
|
||||||
|
label: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
const isDestructive = variant === "destructive";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
width: 180,
|
||||||
|
height: 90,
|
||||||
|
backgroundColor: focused
|
||||||
|
? isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff"
|
||||||
|
: isDestructive
|
||||||
|
? "rgba(239, 68, 68, 0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={28}
|
||||||
|
color={
|
||||||
|
focused
|
||||||
|
? isDestructive
|
||||||
|
? "#fff"
|
||||||
|
: "#000"
|
||||||
|
: isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused
|
||||||
|
? isDestructive
|
||||||
|
? "#fff"
|
||||||
|
: "#000"
|
||||||
|
: isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server action sheet component (bottom sheet with horizontal scrolling)
|
||||||
|
const TVServerActionSheet: React.FC<{
|
||||||
|
visible: boolean;
|
||||||
|
server: SavedServer | null;
|
||||||
|
onLogin: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = ({ visible, server, onLogin, onDelete, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!server) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType='fade'
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{server.name || server.address}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Horizontal options */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVServerActionCard
|
||||||
|
label={t("common.login")}
|
||||||
|
icon='log-in-outline'
|
||||||
|
hasTVPreferredFocus
|
||||||
|
onPress={onLogin}
|
||||||
|
/>
|
||||||
|
<TVServerActionCard
|
||||||
|
label={t("common.delete")}
|
||||||
|
icon='trash-outline'
|
||||||
|
variant='destructive'
|
||||||
|
onPress={onDelete}
|
||||||
|
/>
|
||||||
|
<TVServerActionCard
|
||||||
|
label={t("common.cancel")}
|
||||||
|
icon='close-outline'
|
||||||
|
onPress={onClose}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVPreviousServersListProps {
|
||||||
|
onServerSelect: (server: SavedServer) => void;
|
||||||
|
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
|
||||||
|
onPasswordLogin?: (
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
onAddAccount?: (server: SavedServer) => void;
|
||||||
|
onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void;
|
||||||
|
onPasswordRequired?: (
|
||||||
|
server: SavedServer,
|
||||||
|
account: SavedServerAccount,
|
||||||
|
) => void;
|
||||||
|
// Called when server is pressed to show action sheet (handled by parent)
|
||||||
|
onServerAction?: (server: SavedServer) => void;
|
||||||
|
// Called by parent when "Login" is selected from action sheet
|
||||||
|
loginServerOverride?: SavedServer | null;
|
||||||
|
// Disable all focusable elements (when a modal is open)
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the action sheet for use in parent components
|
||||||
|
export { TVServerActionSheet };
|
||||||
|
|
||||||
|
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
||||||
|
onServerSelect,
|
||||||
|
onQuickLogin,
|
||||||
|
onAddAccount,
|
||||||
|
onPinRequired,
|
||||||
|
onPasswordRequired,
|
||||||
|
onServerAction,
|
||||||
|
loginServerOverride,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [_previousServers, setPreviousServers] =
|
||||||
|
useMMKVString("previousServers");
|
||||||
|
const [loadingServer, setLoadingServer] = useState<string | null>(null);
|
||||||
|
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [showAccountsModal, setShowAccountsModal] = useState(false);
|
||||||
|
|
||||||
|
const previousServers = useMemo(() => {
|
||||||
|
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
||||||
|
}, [_previousServers]);
|
||||||
|
|
||||||
|
// When parent triggers login via loginServerOverride, execute the login flow
|
||||||
|
useEffect(() => {
|
||||||
|
if (loginServerOverride) {
|
||||||
|
const accountCount = loginServerOverride.accounts?.length || 0;
|
||||||
|
|
||||||
|
if (accountCount === 0) {
|
||||||
|
onServerSelect(loginServerOverride);
|
||||||
|
} else if (accountCount === 1) {
|
||||||
|
handleAccountLogin(
|
||||||
|
loginServerOverride,
|
||||||
|
loginServerOverride.accounts[0],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedServer(loginServerOverride);
|
||||||
|
setShowAccountsModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loginServerOverride]);
|
||||||
|
|
||||||
|
const refreshServers = () => {
|
||||||
|
const servers = getPreviousServers();
|
||||||
|
setPreviousServers(JSON.stringify(servers));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountLogin = async (
|
||||||
|
server: SavedServer,
|
||||||
|
account: SavedServerAccount,
|
||||||
|
) => {
|
||||||
|
setShowAccountsModal(false);
|
||||||
|
|
||||||
|
switch (account.securityType) {
|
||||||
|
case "none":
|
||||||
|
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":
|
||||||
|
if (onPinRequired) {
|
||||||
|
onPinRequired(server, account);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "password":
|
||||||
|
if (onPasswordRequired) {
|
||||||
|
onPasswordRequired(server, account);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerPress = (server: SavedServer) => {
|
||||||
|
if (loadingServer) return;
|
||||||
|
|
||||||
|
// If onServerAction is provided, delegate to parent for action sheet handling
|
||||||
|
if (onServerAction) {
|
||||||
|
onServerAction(server);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: direct login flow (for backwards compatibility)
|
||||||
|
const accountCount = server.accounts?.length || 0;
|
||||||
|
if (accountCount === 0) {
|
||||||
|
onServerSelect(server);
|
||||||
|
} else if (accountCount === 1) {
|
||||||
|
handleAccountLogin(server, server.accounts[0]);
|
||||||
|
} else {
|
||||||
|
setSelectedServer(server);
|
||||||
|
setShowAccountsModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async (account: SavedServerAccount) => {
|
||||||
|
if (!selectedServer) 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(
|
||||||
|
selectedServer.address,
|
||||||
|
account.userId,
|
||||||
|
);
|
||||||
|
refreshServers();
|
||||||
|
if (selectedServer.accounts.length <= 1) {
|
||||||
|
setShowAccountsModal(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 32 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("server.previous_servers")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
{previousServers.map((server) => (
|
||||||
|
<TVServerCard
|
||||||
|
key={server.address}
|
||||||
|
title={server.name || server.address}
|
||||||
|
subtitle={getServerSubtitle(server)}
|
||||||
|
securityIcon={getSecurityIcon(server)}
|
||||||
|
isLoading={loadingServer === server.address}
|
||||||
|
onPress={() => handleServerPress(server)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* TV Account Selection Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showAccountsModal}
|
||||||
|
transparent
|
||||||
|
animationType='fade'
|
||||||
|
onRequestClose={() => setShowAccountsModal(false)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 40,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("server.select_account")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedServer?.name || selectedServer?.address}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ gap: 12, marginBottom: 24 }}>
|
||||||
|
{selectedServer?.accounts.map((account, index) => (
|
||||||
|
<TVAccountCard
|
||||||
|
key={account.userId}
|
||||||
|
account={account}
|
||||||
|
onPress={() =>
|
||||||
|
selectedServer &&
|
||||||
|
handleAccountLogin(selectedServer, account)
|
||||||
|
}
|
||||||
|
onLongPress={() => handleDeleteAccount(account)}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setShowAccountsModal(false);
|
||||||
|
if (selectedServer && onAddAccount) {
|
||||||
|
onAddAccount(selectedServer);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
|
{t("server.add_account")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => setShowAccountsModal(false)}
|
||||||
|
color='black'
|
||||||
|
className='bg-neutral-800'
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
435
components/login/TVSaveAccountModal.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
|
||||||
|
import { TVOptionCard, useTVFocusAnimation } from "@/components/tv";
|
||||||
|
import type { AccountSecurityType } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
interface TVSaveAccountModalProps {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Custom Save Button with TV focus
|
||||||
|
const TVSaveButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({ onPress, label, disabled = false, hasTVPreferredFocus = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#a855f7"
|
||||||
|
: disabled
|
||||||
|
? "#4a4a4a"
|
||||||
|
: "#7c3aed",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name='checkmark' size={20} color='#fff' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Back Button for PIN step
|
||||||
|
const TVBackButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-back'
|
||||||
|
size={20}
|
||||||
|
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#000" : "rgba(255,255,255,0.8)",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVSaveAccountModal: React.FC<TVSaveAccountModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [step, setStep] = useState<"select" | "pin">("select");
|
||||||
|
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
|
||||||
|
const [pinCode, setPinCode] = useState("");
|
||||||
|
const [pinError, setPinError] = useState<string | null>(null);
|
||||||
|
const pinInputRef = useRef<TVPinInputRef>(null);
|
||||||
|
|
||||||
|
// Use useState for focus tracking (per TV focus guide)
|
||||||
|
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// Reset state when opening
|
||||||
|
setStep("select");
|
||||||
|
setSelectedType("none");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Focus the first card when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef && step === "select") {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady, firstCardRef, step]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === "pin" && isReady) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pinInputRef.current?.focus();
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [step, isReady]);
|
||||||
|
|
||||||
|
const handleOptionSelect = (type: AccountSecurityType) => {
|
||||||
|
setSelectedType(type);
|
||||||
|
if (type === "pin") {
|
||||||
|
setStep("pin");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
} else {
|
||||||
|
// For "none" or "password", save immediately
|
||||||
|
onSave(type);
|
||||||
|
resetAndClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinSave = () => {
|
||||||
|
if (pinCode.length !== 4) {
|
||||||
|
setPinError(t("pin.enter_4_digits"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave("pin", pinCode);
|
||||||
|
resetAndClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep("select");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAndClose = () => {
|
||||||
|
setStep("select");
|
||||||
|
setSelectedType("none");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("save_account.title")}</Text>
|
||||||
|
<Text style={styles.subtitle}>{username}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{step === "select" ? (
|
||||||
|
// Security selection step
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{t("save_account.security_option")}
|
||||||
|
</Text>
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{SECURITY_OPTIONS.map((option, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={option.type}
|
||||||
|
ref={index === 0 ? setFirstCardRef : undefined}
|
||||||
|
label={t(option.titleKey)}
|
||||||
|
sublabel={t(option.descriptionKey)}
|
||||||
|
selected={selectedType === option.type}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
onPress={() => handleOptionSelect(option.type)}
|
||||||
|
width={220}
|
||||||
|
height={100}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// PIN entry step
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>{t("pin.setup_pin")}</Text>
|
||||||
|
<View style={styles.pinContainer}>
|
||||||
|
<TVPinInput
|
||||||
|
ref={pinInputRef}
|
||||||
|
value={pinCode}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPinCode(text);
|
||||||
|
setPinError(null);
|
||||||
|
}}
|
||||||
|
length={4}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{pinError && <Text style={styles.errorText}>{pinError}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<TVBackButton
|
||||||
|
onPress={handleBack}
|
||||||
|
label={t("common.back")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
<TVSaveButton
|
||||||
|
onPress={handlePinSave}
|
||||||
|
label={t("save_account.save_button")}
|
||||||
|
disabled={pinCode.length !== 4}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
marginTop: 20,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
pinContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
119
components/login/TVSaveAccountToggle.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { Animated, Easing, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface TVSaveAccountToggleProps {
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
label,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const glowOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.02 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(glowOpacity, {
|
||||||
|
toValue: focused ? 0.6 : 0,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onValueChange(!value)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#a855f7",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
{ shadowOpacity: glowOpacity },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
pointerEvents='none'
|
||||||
|
style={{
|
||||||
|
width: 60,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 17,
|
||||||
|
backgroundColor: value ? Colors.primary : "#3f3f46",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: "white",
|
||||||
|
alignSelf: value ? "flex-end" : "flex-start",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
153
components/login/TVServerCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface TVServerCardProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
securityIcon?: keyof typeof Ionicons.glyphMap | null;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
securityIcon,
|
||||||
|
isLoading,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const glowOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.02 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(glowOpacity, {
|
||||||
|
toValue: focused ? 0.7 : 0,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={isDisabled}
|
||||||
|
focusable={!isDisabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !isDisabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#a855f7",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
{ shadowOpacity: glowOpacity },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ marginLeft: 16 }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size='small' color={Colors.primary} />
|
||||||
|
) : securityIcon ? (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||||
|
<Ionicons
|
||||||
|
name={securityIcon}
|
||||||
|
size={20}
|
||||||
|
color={Colors.primary}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-forward'
|
||||||
|
size={24}
|
||||||
|
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-forward'
|
||||||
|
size={24}
|
||||||
|
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -243,7 +243,7 @@ export const MiniPlayerBar: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Animated.View style={[styles.touchable, animatedBarStyle]}>
|
<Animated.View style={[styles.touchable, animatedBarStyle]}>
|
||||||
{Platform.OS === "ios" ? (
|
{Platform.OS === "ios" && !Platform.isTV ? (
|
||||||
<GlassEffectView style={styles.blurContainer}>
|
<GlassEffectView style={styles.blurContainer}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
617
components/persons/TVActorPage.tsx
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
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 { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useSegments } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Easing,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 80;
|
||||||
|
const TOP_PADDING = 140;
|
||||||
|
const ACTOR_IMAGE_SIZE = 250;
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
// Focusable poster wrapper component for TV
|
||||||
|
const TVFocusablePoster: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}> = ({ children, onPress, hasTVPreferredFocus, onFocus, onBlur }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (value: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: value,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
onFocus?.();
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
onBlur?.();
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#ffffff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVActorPageProps {
|
||||||
|
personId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
// Track which filmography item is currently focused for dynamic backdrop
|
||||||
|
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||||
|
|
||||||
|
// Fetch actor details
|
||||||
|
const { data: item, isLoading: isLoadingActor } = useQuery({
|
||||||
|
queryKey: ["item", personId],
|
||||||
|
queryFn: async () =>
|
||||||
|
await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: personId,
|
||||||
|
}),
|
||||||
|
enabled: !!personId && !!api,
|
||||||
|
staleTime: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch movies
|
||||||
|
const { data: movies = [], isLoading: isLoadingMovies } = useQuery({
|
||||||
|
queryKey: ["actor", "movies", personId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
personIds: [personId],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||||
|
includeItemTypes: ["Movie"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["ParentId", "PrimaryImageAspectRatio"],
|
||||||
|
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!personId && !!api && !!user?.Id,
|
||||||
|
staleTime: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch series
|
||||||
|
const { data: series = [], isLoading: isLoadingSeries } = useQuery({
|
||||||
|
queryKey: ["actor", "series", personId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
personIds: [personId],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||||
|
includeItemTypes: ["Series"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["ParentId", "PrimaryImageAspectRatio"],
|
||||||
|
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!personId && !!api && !!user?.Id,
|
||||||
|
staleTime: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get backdrop URL from the currently focused filmography item
|
||||||
|
// Changes dynamically as user navigates through the list
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
// Use focused item if available, otherwise fall back to first movie or series
|
||||||
|
const itemForBackdrop = focusedItem ?? movies[0] ?? series[0];
|
||||||
|
if (!itemForBackdrop) return null;
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: itemForBackdrop,
|
||||||
|
quality: 90,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, focusedItem, movies, series]);
|
||||||
|
|
||||||
|
// Crossfade animation for backdrop transitions
|
||||||
|
// Use two alternating layers for smooth crossfade
|
||||||
|
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
|
||||||
|
const [layer0Url, setLayer0Url] = useState<string | null>(null);
|
||||||
|
const [layer1Url, setLayer1Url] = useState<string | null>(null);
|
||||||
|
const layer0Opacity = useRef(new Animated.Value(1)).current;
|
||||||
|
const layer1Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backdropUrl) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const performCrossfade = async () => {
|
||||||
|
// Prefetch the image before starting the crossfade
|
||||||
|
try {
|
||||||
|
await Image.prefetch(backdropUrl);
|
||||||
|
} catch {
|
||||||
|
// Continue even if prefetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
// Determine which layer to fade in
|
||||||
|
const incomingLayer = activeLayer === 0 ? 1 : 0;
|
||||||
|
const incomingOpacity =
|
||||||
|
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
|
||||||
|
const outgoingOpacity =
|
||||||
|
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
|
||||||
|
|
||||||
|
// Set the new URL on the incoming layer
|
||||||
|
if (incomingLayer === 0) {
|
||||||
|
setLayer0Url(backdropUrl);
|
||||||
|
} else {
|
||||||
|
setLayer1Url(backdropUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure image component has the new URL
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
// Crossfade: fade in the incoming layer, fade out the outgoing
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(incomingOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(outgoingOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
// After animation completes, switch the active layer
|
||||||
|
setActiveLayer(incomingLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
performCrossfade();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [backdropUrl]);
|
||||||
|
|
||||||
|
// Get actor image URL
|
||||||
|
const actorImageUrl = useMemo(() => {
|
||||||
|
if (!item?.Id || !api?.basePath) return null;
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillWidth=${ACTOR_IMAGE_SIZE * 2}&fillHeight=${ACTOR_IMAGE_SIZE * 2}&quality=90`;
|
||||||
|
}, [api?.basePath, item?.Id]);
|
||||||
|
|
||||||
|
// Handle filmography item press
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(filmItem: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(filmItem, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// List item layout
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: TV_POSTER_WIDTH + ITEM_GAP,
|
||||||
|
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render filmography item
|
||||||
|
const renderFilmographyItem = useCallback(
|
||||||
|
(
|
||||||
|
{ item: filmItem, index }: { item: BaseItemDto; index: number },
|
||||||
|
isFirstSection: boolean,
|
||||||
|
) => (
|
||||||
|
<View style={{ marginRight: ITEM_GAP }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(filmItem)}
|
||||||
|
onFocus={() => setFocusedItem(filmItem)}
|
||||||
|
hasTVPreferredFocus={isFirstSection && index === 0}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<MoviePoster item={filmItem} />
|
||||||
|
<View style={{ width: TV_POSTER_WIDTH, marginTop: 8 }}>
|
||||||
|
<ItemCardText item={filmItem} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TVFocusablePoster>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[handleItemPress],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoadingActor) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item?.Id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
{/* Full-screen backdrop with crossfade - two alternating layers */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Layer 0 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer0Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer0Url ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer0Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Layer 1 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer1Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer1Url ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer1Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Gradient overlays for readability */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.8)", "transparent"]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0.6, y: 0 }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "60%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<ScrollView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingBottom: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top section - Actor image + Info */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Circular actor image */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ACTOR_IMAGE_SIZE,
|
||||||
|
height: ACTOR_IMAGE_SIZE,
|
||||||
|
borderRadius: ACTOR_IMAGE_SIZE / 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
marginRight: 50,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: "rgba(255,255,255,0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actorImageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: actorImageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='person'
|
||||||
|
size={80}
|
||||||
|
color='rgba(255,255,255,0.4)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Info */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Actor name */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 42,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Production year / Birth year */}
|
||||||
|
{item.ProductionYear && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Biography */}
|
||||||
|
{item.Overview && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#D1D5DB",
|
||||||
|
lineHeight: 28,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
|
}}
|
||||||
|
numberOfLines={4}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Filmography sections */}
|
||||||
|
<View style={{ flex: 1, overflow: "visible" }}>
|
||||||
|
{/* Movies Section */}
|
||||||
|
{isLoadingMovies ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 300,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
movies.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 32 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.movies")}
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={movies}
|
||||||
|
keyExtractor={(filmItem) => filmItem.Id!}
|
||||||
|
renderItem={(props) => renderFilmographyItem(props, true)}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={6}
|
||||||
|
maxToRenderPerBatch={4}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Series Section */}
|
||||||
|
{isLoadingSeries ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 300,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
series.length > 0 && (
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.shows")}
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={series}
|
||||||
|
keyExtractor={(filmItem) => filmItem.Id!}
|
||||||
|
renderItem={(props) =>
|
||||||
|
renderFilmographyItem(props, movies.length === 0)
|
||||||
|
}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={6}
|
||||||
|
maxToRenderPerBatch={4}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state - only show if both sections are empty and not loading */}
|
||||||
|
{!isLoadingMovies &&
|
||||||
|
!isLoadingSeries &&
|
||||||
|
movies.length === 0 &&
|
||||||
|
series.length === 0 && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#737373",
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.no_results")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
106
components/posters/MoviePoster.tv.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
export const TV_POSTER_WIDTH = 260;
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||||
|
item,
|
||||||
|
showProgress = false,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
return getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
width: 520, // 2x for quality on large screens
|
||||||
|
});
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const progress = item.UserData?.PlayedPercentage || 0;
|
||||||
|
const isWatched = item.UserData?.Played === true;
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
// Use glass effect on tvOS 26+
|
||||||
|
const useGlass = isGlassEffectAvailable();
|
||||||
|
|
||||||
|
if (useGlass) {
|
||||||
|
return (
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={url ?? null}
|
||||||
|
aspectRatio={10 / 15}
|
||||||
|
cornerRadius={24}
|
||||||
|
progress={showProgress ? progress : 0}
|
||||||
|
showWatchedIndicator={isWatched}
|
||||||
|
isFocused={false}
|
||||||
|
width={TV_POSTER_WIDTH}
|
||||||
|
style={{ width: TV_POSTER_WIDTH }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for older tvOS versions
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
{showProgress && progress > 0 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "#dc2626",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MoviePoster;
|
||||||
92
components/posters/SeriesPoster.tv.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
export const TV_POSTER_WIDTH = 260;
|
||||||
|
|
||||||
|
type SeriesPosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=780&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||||
|
}
|
||||||
|
return getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
width: 520, // 2x for quality on large screens
|
||||||
|
});
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
// Use glass effect on tvOS 26+
|
||||||
|
const useGlass = isGlassEffectAvailable();
|
||||||
|
|
||||||
|
if (useGlass) {
|
||||||
|
return (
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={url ?? null}
|
||||||
|
aspectRatio={10 / 15}
|
||||||
|
cornerRadius={24}
|
||||||
|
progress={0}
|
||||||
|
showWatchedIndicator={false}
|
||||||
|
isFocused={false}
|
||||||
|
width={TV_POSTER_WIDTH}
|
||||||
|
style={{ width: TV_POSTER_WIDTH }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for older tvOS versions
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
position: "relative",
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SeriesPoster;
|
||||||
454
components/search/TVJellyseerrSearchResults.tsx
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
PersonResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
interface TVJellyseerrPosterProps {
|
||||||
|
item: MovieResult | TvResult;
|
||||||
|
onPress: () => void;
|
||||||
|
isFirstItem?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||||
|
item,
|
||||||
|
onPress,
|
||||||
|
isFirstItem = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
|
const posterUrl = item.posterPath
|
||||||
|
? jellyseerrApi?.imageProxy(item.posterPath, "w342")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const title = getTitle(item);
|
||||||
|
const year = getYear(item);
|
||||||
|
|
||||||
|
const isInLibrary =
|
||||||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirstItem}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
width: 210,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 210,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='image-outline'
|
||||||
|
size={40}
|
||||||
|
color='rgba(255,255,255,0.3)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isInLibrary && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.9)",
|
||||||
|
borderRadius: 14,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='checkmark' size={18} color='black' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{year && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVJellyseerrPersonPosterProps {
|
||||||
|
item: PersonResult;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||||
|
item,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
|
const posterUrl = item.profilePath
|
||||||
|
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
width: 160,
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
borderRadius: 70,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
borderWidth: focused ? 3 : 0,
|
||||||
|
borderColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginTop: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVJellyseerrMovieSectionProps {
|
||||||
|
title: string;
|
||||||
|
items: MovieResult[];
|
||||||
|
isFirstSection?: boolean;
|
||||||
|
onItemPress: (item: MovieResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
isFirstSection = false,
|
||||||
|
onItemPress,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TVJellyseerrPoster
|
||||||
|
item={item}
|
||||||
|
onPress={() => onItemPress(item)}
|
||||||
|
isFirstItem={isFirstSection && index === 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVJellyseerrTvSectionProps {
|
||||||
|
title: string;
|
||||||
|
items: TvResult[];
|
||||||
|
isFirstSection?: boolean;
|
||||||
|
onItemPress: (item: TvResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
isFirstSection = false,
|
||||||
|
onItemPress,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TVJellyseerrPoster
|
||||||
|
item={item}
|
||||||
|
onPress={() => onItemPress(item)}
|
||||||
|
isFirstItem={isFirstSection && index === 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVJellyseerrPersonSectionProps {
|
||||||
|
title: string;
|
||||||
|
items: PersonResult[];
|
||||||
|
isFirstSection?: boolean;
|
||||||
|
onItemPress: (item: PersonResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
isFirstSection: _isFirstSection = false,
|
||||||
|
onItemPress,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TVJellyseerrPersonPoster
|
||||||
|
item={item}
|
||||||
|
onPress={() => onItemPress(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TVJellyseerrSearchResultsProps {
|
||||||
|
movieResults: MovieResult[];
|
||||||
|
tvResults: TvResult[];
|
||||||
|
personResults: PersonResult[];
|
||||||
|
loading: boolean;
|
||||||
|
noResults: boolean;
|
||||||
|
searchQuery: string;
|
||||||
|
onMoviePress: (item: MovieResult) => void;
|
||||||
|
onTvPress: (item: TvResult) => void;
|
||||||
|
onPersonPress: (item: PersonResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVJellyseerrSearchResults: React.FC<
|
||||||
|
TVJellyseerrSearchResultsProps
|
||||||
|
> = ({
|
||||||
|
movieResults,
|
||||||
|
tvResults,
|
||||||
|
personResults,
|
||||||
|
loading,
|
||||||
|
noResults,
|
||||||
|
searchQuery,
|
||||||
|
onMoviePress,
|
||||||
|
onTvPress,
|
||||||
|
onPersonPress,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const hasMovies = movieResults && movieResults.length > 0;
|
||||||
|
const hasTv = tvResults && tvResults.length > 0;
|
||||||
|
const hasPersons = personResults && personResults.length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noResults && searchQuery.length > 0) {
|
||||||
|
return (
|
||||||
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("search.no_results_found_for")}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||||
|
"{searchQuery}"
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TVJellyseerrMovieSection
|
||||||
|
title={t("search.request_movies")}
|
||||||
|
items={movieResults}
|
||||||
|
isFirstSection={hasMovies}
|
||||||
|
onItemPress={onMoviePress}
|
||||||
|
/>
|
||||||
|
<TVJellyseerrTvSection
|
||||||
|
title={t("search.request_series")}
|
||||||
|
items={tvResults}
|
||||||
|
isFirstSection={!hasMovies && hasTv}
|
||||||
|
onItemPress={onTvPress}
|
||||||
|
/>
|
||||||
|
<TVJellyseerrPersonSection
|
||||||
|
title={t("search.actors")}
|
||||||
|
items={personResults}
|
||||||
|
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||||
|
onItemPress={onPersonPress}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
components/search/TVSearchBadge.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
export interface TVSearchBadgeProps {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#000" : "#fff",
|
||||||
|
fontWeight: focused ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||