Compare commits
140 Commits
feat/disab
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c21054d33 | ||
|
|
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 | ||
|
|
c7077bbcfe | ||
|
|
c0f25a2b8b | ||
|
|
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 | ||
|
|
36304ad58e | ||
|
|
baeb83581e | ||
|
|
05b7a4c50d | ||
|
|
28b67f3ad6 | ||
|
|
51cd195bfe | ||
|
|
0184e266a0 | ||
|
|
ae658aa5b0 | ||
|
|
81f79a54af | ||
|
|
ca1b640a61 | ||
|
|
e771949c95 | ||
|
|
78bfa68a17 | ||
|
|
ac59615d79 | ||
|
|
4dd80cd8f5 | ||
|
|
db9f02b225 | ||
|
|
7a0bbb1084 | ||
|
|
05925530c0 | ||
|
|
625a292e26 | ||
|
|
1acd3102ea | ||
|
|
543881dc41 | ||
|
|
5d93483dc2 | ||
|
|
d54a29020a | ||
|
|
1d04e39b85 | ||
|
|
ecc62259fc | ||
|
|
ffd96e05fe | ||
|
|
8541ba02d4 | ||
|
|
6c955d8a2a | ||
|
|
b0bb6c6c9a | ||
|
|
82abc291d4 | ||
|
|
3da4b42ca3 | ||
|
|
16940075b2 | ||
|
|
a3bbb1bc3a | ||
|
|
1874c116a6 | ||
|
|
7a0f70778d | ||
|
|
6957c4fd64 | ||
|
|
1c0ed82deb | ||
|
|
ad54823f96 | ||
|
|
cfa638afc6 | ||
|
|
467bea7192 | ||
|
|
ac9ac5d423 | ||
|
|
62b45121e5 | ||
|
|
0e238ad10e | ||
|
|
ce793e3469 | ||
|
|
beba4853b9 |
@@ -25,3 +25,9 @@ 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)_
|
||||
|
||||
- **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)_
|
||||
89
.github/workflows/build-apps.yml
vendored
@@ -20,6 +20,18 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
@@ -91,6 +103,18 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
@@ -194,7 +218,7 @@ jobs:
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||
with:
|
||||
xcode-version: "26.0.1"
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
@@ -203,9 +227,6 @@ jobs:
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: ⚙️ Ensure iOS SDKs installed
|
||||
run: xcodebuild -downloadPlatform iOS
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
@@ -221,6 +242,63 @@ jobs:
|
||||
path: build-*.ipa
|
||||
retention-days: 7
|
||||
|
||||
build-ios-phone-unsigned:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
run: bun run ios:unsigned-build ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
|
||||
# Disabled for now - uncomment when ready to build iOS TV
|
||||
# build-ios-tv:
|
||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
@@ -271,9 +349,6 @@ jobs:
|
||||
# token: ${{ secrets.EXPO_TOKEN }}
|
||||
# eas-cache: true
|
||||
#
|
||||
# - name: ⚙️ Ensure tvOS SDKs installed
|
||||
# run: xcodebuild -downloadPlatform tvOS
|
||||
#
|
||||
# - name: 🚀 Build iOS app
|
||||
# env:
|
||||
# EXPO_TV: 1
|
||||
|
||||
6
.github/workflows/ci-codeql.yml
vendored
@@ -27,13 +27,13 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
|
||||
2
.github/workflows/linting.yml
vendored
@@ -107,7 +107,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
|
||||
|
||||
1
.github/workflows/notification.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: 🛎️ Discord Notification
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
2
.github/workflows/update-issue-form.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
8
.gitignore
vendored
@@ -50,8 +50,6 @@ npm-debug.*
|
||||
.idea/
|
||||
.ruby-lsp
|
||||
.cursor/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Environment and Configuration
|
||||
expo-env.d.ts
|
||||
@@ -66,7 +64,11 @@ streamyfin-4fec1-firebase-adminsdk.json
|
||||
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
modules/background-downloader/android/build/*
|
||||
/modules/sf-player/android/build
|
||||
/modules/music-controls/android/build
|
||||
modules/background-downloader/android/build/*
|
||||
/modules/mpv-player/android/build
|
||||
|
||||
# ios:unsigned-build Artifacts
|
||||
build/
|
||||
.claude/settings.local.json
|
||||
|
||||
225
CLAUDE.md
@@ -77,6 +77,21 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
|
||||
- File-based routing in `app/` directory
|
||||
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
||||
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
||||
- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation:
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
const router = useRouter();
|
||||
|
||||
// ❌ Never use this
|
||||
import { useRouter } from "expo-router";
|
||||
import { router } from "expo-router";
|
||||
```
|
||||
|
||||
**Offline Mode**:
|
||||
- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content
|
||||
- Use `useOfflineMode()` hook to check if current context is offline
|
||||
- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context
|
||||
|
||||
**Providers** (wrapping order in `app/_layout.tsx`):
|
||||
1. JotaiProvider
|
||||
@@ -119,3 +134,213 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
- TV version uses `:tv` suffix for scripts
|
||||
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
||||
- Some features disabled on TV (e.g., notifications, Chromecast)
|
||||
- **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.
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/aJvAYeycyY">
|
||||
<img alt="Streamyfin Discord" src="https://img.shields.io/badge/Discord-Streamyfin-blue?style=flat-square&logo=discord">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||
|
||||
---
|
||||
|
||||
30
app.json
@@ -17,17 +17,22 @@
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||
"UIBackgroundModes": ["audio", "fetch"],
|
||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||
"NSLocationWhenInUseUsageDescription": "Streamyfin uses your location to detect your home WiFi network for automatic local server switching.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"UISupportsTrueScreenSizeOnMac": true,
|
||||
"UIFileSharingEnabled": true,
|
||||
"LSSupportsOpeningDocumentsInPlace": true
|
||||
"LSSupportsOpeningDocumentsInPlace": true,
|
||||
"AVInitialRouteSharingPolicy": "LongFormAudio"
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"supportsTablet": true,
|
||||
"entitlements": {
|
||||
"com.apple.developer.networking.wifi-info": true
|
||||
},
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||
"appleTeamId": "MWD5K362T8"
|
||||
@@ -44,13 +49,30 @@
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
"android.permission.WRITE_SETTINGS"
|
||||
"android.permission.WRITE_SETTINGS",
|
||||
"android.permission.ACCESS_FINE_LOCATION"
|
||||
],
|
||||
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
||||
"googleServicesFile": "./google-services.json"
|
||||
},
|
||||
"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-font",
|
||||
"./plugins/withExcludeMedia3Dash.js",
|
||||
@@ -116,6 +138,8 @@
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withTVOSAppIcon.js"],
|
||||
["./plugins/withTVXcodeEnv.js"],
|
||||
[
|
||||
"./plugins/withGitPod.js",
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: Platform.OS !== "ios",
|
||||
headerShown: !Platform.isTV,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.custom_links"),
|
||||
headerBlurEffect: "none",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
|
||||
export default function favorites() {
|
||||
@@ -15,6 +16,10 @@ export default function favorites() {
|
||||
}, []);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (Platform.isTV) {
|
||||
return <TVFavorites />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
|
||||
@@ -42,7 +43,7 @@ export default function IndexLayout() {
|
||||
<Stack.Screen
|
||||
name='downloads/index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
title: t("home.downloads.downloads_title"),
|
||||
@@ -57,30 +58,11 @@ export default function IndexLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='downloads/[seriesId]'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
title: t("home.downloads.tvseries"),
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='sessions/index'
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
headerShown: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -99,6 +81,7 @@ export default function IndexLayout() {
|
||||
name='settings'
|
||||
options={{
|
||||
title: t("home.settings.settings_title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -117,6 +100,7 @@ export default function IndexLayout() {
|
||||
name='settings/playback-controls/page'
|
||||
options={{
|
||||
title: t("home.settings.playback_controls.title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -135,6 +119,7 @@ export default function IndexLayout() {
|
||||
name='settings/audio-subtitles/page'
|
||||
options={{
|
||||
title: t("home.settings.audio_subtitles.title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -153,6 +138,7 @@ export default function IndexLayout() {
|
||||
name='settings/appearance/page'
|
||||
options={{
|
||||
title: t("home.settings.appearance.title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -171,6 +157,7 @@ export default function IndexLayout() {
|
||||
name='settings/music/page'
|
||||
options={{
|
||||
title: t("home.settings.music.title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -189,6 +176,7 @@ export default function IndexLayout() {
|
||||
name='settings/appearance/hide-libraries/page'
|
||||
options={{
|
||||
title: t("home.settings.other.hide_libraries"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -207,6 +195,7 @@ export default function IndexLayout() {
|
||||
name='settings/plugins/page'
|
||||
options={{
|
||||
title: t("home.settings.plugins.plugins_title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -225,6 +214,7 @@ export default function IndexLayout() {
|
||||
name='settings/plugins/marlin-search/page'
|
||||
options={{
|
||||
title: "Marlin Search",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -243,6 +233,7 @@ export default function IndexLayout() {
|
||||
name='settings/plugins/jellyseerr/page'
|
||||
options={{
|
||||
title: "Jellyseerr",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -261,6 +252,7 @@ export default function IndexLayout() {
|
||||
name='settings/plugins/streamystats/page'
|
||||
options={{
|
||||
title: "Streamystats",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -279,6 +271,7 @@ export default function IndexLayout() {
|
||||
name='settings/plugins/kefinTweaks/page'
|
||||
options={{
|
||||
title: "KefinTweaks",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -297,6 +290,7 @@ export default function IndexLayout() {
|
||||
name='settings/intro/page'
|
||||
options={{
|
||||
title: t("home.settings.intro.title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -315,6 +309,26 @@ export default function IndexLayout() {
|
||||
name='settings/logs/page'
|
||||
options={{
|
||||
title: t("home.settings.logs.logs_title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/network/page'
|
||||
options={{
|
||||
title: t("home.settings.network.title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -341,7 +355,7 @@ export default function IndexLayout() {
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerShown: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const local = useLocalSearchParams();
|
||||
const { seriesId, episodeSeasonIndex } = local as {
|
||||
seriesId: string;
|
||||
episodeSeasonIndex: number | string | undefined;
|
||||
};
|
||||
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||
{},
|
||||
);
|
||||
const { downloadedItems, deleteItems } = useDownload();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
downloadedItems
|
||||
?.filter((f) => f.item.SeriesId === seriesId)
|
||||
?.sort(
|
||||
(a, b) =>
|
||||
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
|
||||
) || []
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [downloadedItems, seriesId]);
|
||||
|
||||
// Group episodes by season in a single pass
|
||||
const seasonGroups = useMemo(() => {
|
||||
const groups: Record<number, BaseItemDto[]> = {};
|
||||
|
||||
series.forEach((episode) => {
|
||||
const seasonNumber = episode.item.ParentIndexNumber;
|
||||
if (seasonNumber !== undefined && seasonNumber !== null) {
|
||||
if (!groups[seasonNumber]) {
|
||||
groups[seasonNumber] = [];
|
||||
}
|
||||
groups[seasonNumber].push(episode.item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort episodes within each season
|
||||
Object.values(groups).forEach((episodes) => {
|
||||
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [series]);
|
||||
|
||||
// Get unique seasons (just the season numbers, sorted)
|
||||
const uniqueSeasons = useMemo(() => {
|
||||
const seasonNumbers = Object.keys(seasonGroups)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
|
||||
}, [seasonGroups]);
|
||||
|
||||
const seasonIndex =
|
||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
|
||||
episodeSeasonIndex ??
|
||||
series?.[0]?.item?.ParentIndexNumber ??
|
||||
"";
|
||||
|
||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||
return seasonGroups[Number(seasonIndex)] ?? [];
|
||||
}, [seasonGroups, seasonIndex]);
|
||||
|
||||
const initialSeasonIndex = useMemo(
|
||||
() =>
|
||||
groupBySeason?.[0]?.ParentIndexNumber ??
|
||||
series?.[0]?.item?.ParentIndexNumber,
|
||||
[groupBySeason, series],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (series.length > 0) {
|
||||
navigation.setOptions({
|
||||
title: series[0].item.SeriesName,
|
||||
});
|
||||
} else {
|
||||
storage.remove(seriesId);
|
||||
router.back();
|
||||
}
|
||||
}, [series]);
|
||||
|
||||
const deleteSeries = useCallback(() => {
|
||||
Alert.alert(
|
||||
"Delete season",
|
||||
"Are you sure you want to delete the entire season?",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
onPress: () =>
|
||||
deleteItems(
|
||||
groupBySeason
|
||||
.map((item) => item.Id)
|
||||
.filter((id) => id !== undefined),
|
||||
),
|
||||
style: "destructive",
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [groupBySeason, deleteItems]);
|
||||
|
||||
const ListHeaderComponent = useCallback(() => {
|
||||
if (series.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className='flex flex-row items-center justify-start pb-2'>
|
||||
<SeasonDropdown
|
||||
item={series[0].item}
|
||||
seasons={uniqueSeasons}
|
||||
state={seasonIndexState}
|
||||
initialSeasonIndex={initialSeasonIndex!}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
||||
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
||||
</View>
|
||||
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
||||
<TouchableOpacity onPress={deleteSeries}>
|
||||
<Ionicons name='trash' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [
|
||||
series,
|
||||
uniqueSeasons,
|
||||
seasonIndexState,
|
||||
initialSeasonIndex,
|
||||
groupBySeason,
|
||||
deleteSeries,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View className='flex-1'>
|
||||
<FlashList
|
||||
key={seasonIndex}
|
||||
data={groupBySeason}
|
||||
renderItem={({ item }) => <EpisodeCard item={item} />}
|
||||
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingLeft: insets.left + 16,
|
||||
paddingRight: insets.right + 16,
|
||||
paddingTop: Platform.OS === "android" ? 10 : 8,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -13,8 +13,10 @@ import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
@@ -161,145 +163,99 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
{/* Queue card - hidden */}
|
||||
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<OfflineModeProvider isOffline={true}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-70 text-red-600'>
|
||||
{t("home.downloads.queue_hint")}
|
||||
</Text>
|
||||
<View className='flex flex-col space-y-2 mt-2'>
|
||||
{queue.map((q, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
||||
key={index}
|
||||
>
|
||||
<View>
|
||||
<Text className='font-semibold'>{q.item.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{q.item.Type}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeProcess(q.id);
|
||||
setQueue((prev) => {
|
||||
if (!prev) return [];
|
||||
return [...prev.filter((i) => i.id !== q.id)];
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name='close' size={24} color='red' />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
</Text>
|
||||
)}
|
||||
</View> */}
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{otherMedia.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.other_media")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
|
||||
</View>
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{otherMedia?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
)}
|
||||
|
||||
{otherMedia.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.other_media")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{otherMedia?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{otherMedia?.map((item) => (
|
||||
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
@@ -11,9 +11,14 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function settings() {
|
||||
// TV-specific settings component
|
||||
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
||||
|
||||
// Mobile settings component
|
||||
function SettingsMobile() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [_user] = useAtom(userAtom);
|
||||
@@ -90,6 +95,11 @@ export default function settings() {
|
||||
showArrow
|
||||
title={t("home.settings.intro.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/network/page")}
|
||||
showArrow
|
||||
title={t("home.settings.network.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/logs/page")}
|
||||
showArrow
|
||||
@@ -98,8 +108,17 @@ export default function settings() {
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
{!Platform.isTV && <StorageSettings />}
|
||||
<StorageSettings />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function settings() {
|
||||
// Use TV settings component on TV platforms
|
||||
if (Platform.isTV && SettingsTV) {
|
||||
return <SettingsTV />;
|
||||
}
|
||||
|
||||
return <SettingsMobile />;
|
||||
}
|
||||
|
||||
385
app/(auth)/(tabs)/(home)/settings.tv.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
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 { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { AudioTranscodeMode, 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();
|
||||
|
||||
// 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";
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
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: 42,
|
||||
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: 14,
|
||||
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: 12,
|
||||
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")} />
|
||||
<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 })}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
48
app/(auth)/(tabs)/(home)/settings/network/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { LocalNetworkSettings } from "@/components/settings/LocalNetworkSettings";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export default function NetworkSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const remoteUrl = storage.getString("serverUrl");
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom + 20,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<ListGroup title={t("home.settings.network.current_server")}>
|
||||
<ListItem
|
||||
title={t("home.settings.network.remote_url")}
|
||||
subtitle={remoteUrl ?? t("home.settings.network.not_configured")}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.network.active_url")}
|
||||
subtitle={api?.basePath ?? t("home.settings.network.not_connected")}
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<View className='mt-4'>
|
||||
<LocalNetworkSettings />
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,8 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
@@ -13,6 +12,11 @@ import Animated, {
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
const ItemContentSkeletonTV = Platform.isTV
|
||||
? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV
|
||||
: null;
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
@@ -23,7 +27,11 @@ const Page: React.FC = () => {
|
||||
|
||||
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
||||
// (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.MediaSourceCount,
|
||||
ItemFields.MediaStreams,
|
||||
@@ -39,33 +47,14 @@ const Page: React.FC = () => {
|
||||
};
|
||||
});
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
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);
|
||||
};
|
||||
|
||||
// Fast fade out when item loads (no setTimeout delay)
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
fadeOut(() => {});
|
||||
opacity.value = withTiming(0, { duration: 150 });
|
||||
} else {
|
||||
fadeIn(() => {});
|
||||
opacity.value = withTiming(1, { duration: 150 });
|
||||
}
|
||||
}, [item]);
|
||||
}, [item, opacity]);
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
@@ -75,39 +64,50 @@ const Page: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='flex flex-1 relative'>
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
style={[animatedStyle]}
|
||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: item?.Type === "Episode" ? 300 : 450,
|
||||
}}
|
||||
className='bg-transparent rounded-lg mb-4 w-full'
|
||||
/>
|
||||
<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' />
|
||||
</Animated.View>
|
||||
{item && (
|
||||
<OfflineModeProvider isOffline={isOffline}>
|
||||
<View className='flex flex-1 relative'>
|
||||
{/* Always render ItemContent - it handles loading state internally on TV */}
|
||||
<ItemContent
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
itemWithSources={itemWithSources}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Skeleton overlay - fades out when content loads */}
|
||||
{!item && (
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
style={[animatedStyle]}
|
||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
|
||||
>
|
||||
{Platform.isTV && ItemContentSkeletonTV ? (
|
||||
<ItemContentSkeletonTV />
|
||||
) : (
|
||||
<View style={{ paddingHorizontal: 16, width: "100%" }}>
|
||||
<View
|
||||
style={{
|
||||
height: 450,
|
||||
}}
|
||||
className='bg-transparent rounded-lg mb-4 w-full'
|
||||
/>
|
||||
<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>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -21,12 +21,14 @@ import { GenreTags } from "@/components/GenreTags";
|
||||
import Cast from "@/components/jellyseerr/Cast";
|
||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||
import { TVJellyseerrPage } from "@/components/jellyseerr/tv";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
@@ -51,7 +53,8 @@ import type {
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
// Mobile page component
|
||||
const MobilePage: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
@@ -541,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;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
@@ -15,6 +15,7 @@ import { Loader } from "@/components/Loader";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { TVActorPage } from "@/components/persons/TVActorPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
@@ -23,6 +24,16 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
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 [api] = useAtom(apiAtom);
|
||||
|
||||
@@ -14,86 +14,125 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||
import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import {
|
||||
buildOfflineSeriesFromEpisodes,
|
||||
getDownloadedEpisodesForSeries,
|
||||
} from "@/utils/downloads/offline-series";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const params = useLocalSearchParams();
|
||||
const { id: seriesId, seasonIndex } = params as {
|
||||
const {
|
||||
id: seriesId,
|
||||
seasonIndex,
|
||||
offline: offlineParam,
|
||||
} = params as {
|
||||
id: string;
|
||||
seasonIndex: string;
|
||||
offline?: string;
|
||||
};
|
||||
|
||||
const isOffline = offlineParam === "true";
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { getDownloadedItems, downloadedItems } = useDownload();
|
||||
|
||||
// For offline mode, construct series data from downloaded episodes
|
||||
// Include downloadedItems.length so query refetches when items are deleted
|
||||
const { data: item } = useQuery({
|
||||
queryKey: ["series", seriesId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
queryKey: ["series", seriesId, isOffline, downloadedItems.length],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
|
||||
}
|
||||
return await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: seriesId,
|
||||
}),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
},
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
// For offline mode, use stored base64 image
|
||||
const base64Image = useMemo(() => {
|
||||
if (isOffline) {
|
||||
return storage.getString(seriesId);
|
||||
}
|
||||
return null;
|
||||
}, [isOffline, seriesId]);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() =>
|
||||
getLogoImageUrlById({
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
const backdropUrl = useMemo(() => {
|
||||
if (isOffline && base64Image) {
|
||||
return `data:image/jpeg;base64,${base64Image}`;
|
||||
}
|
||||
return getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
});
|
||||
}, [isOffline, base64Image, api, item]);
|
||||
|
||||
const logoUrl = useMemo(() => {
|
||||
if (isOffline) {
|
||||
return null; // No logo in offline mode
|
||||
}
|
||||
return getLogoImageUrlById({
|
||||
api,
|
||||
item,
|
||||
});
|
||||
}, [isOffline, api, item]);
|
||||
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["AllEpisodes", item?.Id],
|
||||
queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.Id) return [];
|
||||
if (isOffline) {
|
||||
return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
|
||||
}
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.Id,
|
||||
seriesId: seriesId,
|
||||
userId: user.Id,
|
||||
enableUserData: true,
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
select: (data) =>
|
||||
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
||||
[...(data || [])].sort(
|
||||
(a, b) =>
|
||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||
),
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
staleTime: isOffline ? Infinity : 60,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Don't show header buttons in offline mode
|
||||
if (isOffline) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
!isLoading &&
|
||||
item &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{!Platform.isTV && (
|
||||
@@ -114,49 +153,77 @@ const page: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
) : null,
|
||||
});
|
||||
}, [allEpisodes, isLoading, item]);
|
||||
}, [allEpisodes, isLoading, item, isOffline]);
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
// For offline mode, we can show the page even without backdropUrl
|
||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||
|
||||
// TV version
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<OfflineModeProvider isOffline={isOffline}>
|
||||
<TVSeriesPage
|
||||
item={item}
|
||||
allEpisodes={allEpisodes}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={
|
||||
logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
}}
|
||||
contentFit='contain'
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col pt-4'>
|
||||
<SeriesHeader item={item} />
|
||||
<View className='mb-4'>
|
||||
<NextUp seriesId={seriesId} />
|
||||
<OfflineModeProvider isOffline={isOffline}>
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
backdropUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
logo={
|
||||
logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
}}
|
||||
contentFit='contain'
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col pt-4'>
|
||||
<SeriesHeader item={item} />
|
||||
{!isOffline && (
|
||||
<View className='mb-4'>
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
)}
|
||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||
</View>
|
||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</ParallaxScrollView>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
@@ -11,19 +12,44 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
FlatList,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import {
|
||||
getItemNavigation,
|
||||
TouchableItemRouter,
|
||||
} from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
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 useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -49,6 +75,280 @@ import {
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const TV_ITEM_GAP = 16;
|
||||
const TV_SCALE_PADDING = 20;
|
||||
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// TV Filter Types and Components
|
||||
type TVFilterModalType =
|
||||
| "genre"
|
||||
| "year"
|
||||
| "tags"
|
||||
| "sortBy"
|
||||
| "sortOrder"
|
||||
| "filterBy"
|
||||
| null;
|
||||
|
||||
interface TVFilterOption<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const TVFilterOptionCard: React.FC<{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, selected, 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();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
width: 160,
|
||||
height: 75,
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: selected
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: focused || selected ? "600" : "400",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{selected && !focused && (
|
||||
<View style={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.8)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const TVFilterButton: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
hasActiveFilter?: boolean;
|
||||
}> = ({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
hasTVPreferredFocus,
|
||||
disabled,
|
||||
hasActiveFilter,
|
||||
}) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.04);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale }] }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: hasActiveFilter
|
||||
? "rgba(255, 255, 255, 0.25)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
borderRadius: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
borderWidth: hasActiveFilter && !focused ? 1 : 0,
|
||||
borderColor: "rgba(255, 255, 255, 0.4)",
|
||||
}}
|
||||
>
|
||||
{label ? (
|
||||
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused ? "#000" : "#FFFFFF",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const TVFilterSelector = <T,>({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
options: TVFilterOption<T>[];
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
// Track initial focus index - only set once when modal opens
|
||||
const initialFocusIndexRef = useRef<number | null>(null);
|
||||
|
||||
// Calculate initial focus index only once when visible becomes true
|
||||
if (visible && initialFocusIndexRef.current === null) {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
initialFocusIndexRef.current = idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
// Reset when modal closes
|
||||
if (!visible) {
|
||||
initialFocusIndexRef.current = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialFocusIndex = initialFocusIndexRef.current ?? 0;
|
||||
|
||||
return (
|
||||
<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,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingVertical: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVFilterOptionCard
|
||||
key={String(option.value)}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialFocusIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams() as {
|
||||
libraryId: string;
|
||||
@@ -79,6 +379,54 @@ const Page = () => {
|
||||
const { orientation } = useOrientation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
// TV Filter modal state
|
||||
const [openFilterModal, setOpenFilterModal] =
|
||||
useState<TVFilterModalType>(null);
|
||||
const isFilterModalOpen = openFilterModal !== null;
|
||||
|
||||
const isFiltersDisabled = isFilterModalOpen;
|
||||
|
||||
// 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(() => {
|
||||
// Check for URL params first (from "See All" navigation)
|
||||
@@ -162,6 +510,14 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (Platform.isTV) {
|
||||
// Calculate columns based on TV poster width + gap
|
||||
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||
);
|
||||
}
|
||||
if (screenWidth < 300) return 2;
|
||||
if (screenWidth < 500) return 3;
|
||||
if (screenWidth < 800) return 5;
|
||||
@@ -209,6 +565,10 @@ const Page = () => {
|
||||
itemType = "Series";
|
||||
} else if (library.CollectionType === "boxsets") {
|
||||
itemType = "BoxSet";
|
||||
} else if (library.CollectionType === "homevideos") {
|
||||
itemType = "Video";
|
||||
} else if (library.CollectionType === "musicvideos") {
|
||||
itemType = "MusicVideo";
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
@@ -318,7 +678,38 @@ const Page = () => {
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation],
|
||||
[orientation, nrOfCols],
|
||||
);
|
||||
|
||||
const renderTVItem = useCallback(
|
||||
({ item }: { item: BaseItemDto }) => {
|
||||
const handlePress = () => {
|
||||
const navTarget = getItemNavigation(item, "(libraries)");
|
||||
router.push(navTarget as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginRight: TV_ITEM_GAP,
|
||||
marginBottom: TV_ITEM_GAP,
|
||||
width: TV_POSTER_WIDTH,
|
||||
}}
|
||||
>
|
||||
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
|
||||
{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, isFilterModalOpen],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
@@ -505,6 +896,156 @@ 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(
|
||||
(): TVFilterOption<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(
|
||||
(): TVFilterOption<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(
|
||||
(): TVFilterOption<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(
|
||||
(): TVFilterOption<SortByOption>[] =>
|
||||
sortOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: sortBy[0] === option.key,
|
||||
})),
|
||||
[sortBy],
|
||||
);
|
||||
|
||||
const tvSortOrderOptions = useMemo(
|
||||
(): TVFilterOption<SortOrderOption>[] =>
|
||||
sortOrderOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: sortOrder[0] === option.key,
|
||||
})),
|
||||
[sortOrder],
|
||||
);
|
||||
|
||||
const tvFilterByOptions = useMemo(
|
||||
(): TVFilterOption<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
|
||||
const handleGenreSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedGenres([]);
|
||||
} else if (selectedGenres.includes(value)) {
|
||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||
} else {
|
||||
setSelectedGenres([...selectedGenres, value]);
|
||||
}
|
||||
},
|
||||
[selectedGenres, setSelectedGenres],
|
||||
);
|
||||
|
||||
const handleYearSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedYears([]);
|
||||
} else if (selectedYears.includes(value)) {
|
||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||
} else {
|
||||
setSelectedYears([...selectedYears, value]);
|
||||
}
|
||||
},
|
||||
[selectedYears, setSelectedYears],
|
||||
);
|
||||
|
||||
const handleTagSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedTags([]);
|
||||
} else if (selectedTags.includes(value)) {
|
||||
setSelectedTags(selectedTags.filter((t) => t !== value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, value]);
|
||||
}
|
||||
},
|
||||
[selectedTags, setSelectedTags],
|
||||
);
|
||||
|
||||
const handleFilterBySelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "__all__") {
|
||||
_setFilterBy([]);
|
||||
} else {
|
||||
setFilter([value as FilterByOption]);
|
||||
}
|
||||
},
|
||||
[setFilter, _setFilterBy],
|
||||
);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading || isLibraryLoading)
|
||||
@@ -514,43 +1055,227 @@ const Page = () => {
|
||||
</View>
|
||||
);
|
||||
|
||||
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();
|
||||
// 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>
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={1}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
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 overlays - filter bar outside FlatList to fix focus boundary issues
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Background content - disabled when modal is open */}
|
||||
<View
|
||||
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
|
||||
focusable={!isFilterModalOpen}
|
||||
isTVSelectable={!isFilterModalOpen}
|
||||
pointerEvents={isFilterModalOpen ? "none" : "auto"}
|
||||
accessibilityElementsHidden={isFilterModalOpen}
|
||||
importantForAccessibility={
|
||||
isFilterModalOpen ? "no-hide-descendants" : "auto"
|
||||
}
|
||||
>
|
||||
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
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}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter
|
||||
/>
|
||||
)}
|
||||
<TVFilterButton
|
||||
label={t("library.filters.genres")}
|
||||
value={
|
||||
selectedGenres.length > 0
|
||||
? `${selectedGenres.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("genre")}
|
||||
hasTVPreferredFocus={!hasActiveFilters}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={selectedGenres.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.years")}
|
||||
value={
|
||||
selectedYears.length > 0
|
||||
? `${selectedYears.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("year")}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={selectedYears.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.tags")}
|
||||
value={
|
||||
selectedTags.length > 0
|
||||
? `${selectedTags.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("tags")}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={selectedTags.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_by")}
|
||||
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||
onPress={() => setOpenFilterModal("sortBy")}
|
||||
disabled={isFiltersDisabled}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_order")}
|
||||
value={
|
||||
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||
}
|
||||
onPress={() => setOpenFilterModal("sortOrder")}
|
||||
disabled={isFiltersDisabled}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.filter_by")}
|
||||
value={
|
||||
filterBy.length > 0
|
||||
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("filterBy")}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={filterBy.length > 0}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
|
||||
<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("library.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderTVItem}
|
||||
extraData={[orientation, nrOfCols, isFilterModalOpen]}
|
||||
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>
|
||||
|
||||
{/* TV Filter Overlays */}
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "genre"}
|
||||
title={t("library.filters.genres")}
|
||||
options={tvGenreFilterOptions}
|
||||
onSelect={handleGenreSelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "year"}
|
||||
title={t("library.filters.years")}
|
||||
options={tvYearFilterOptions}
|
||||
onSelect={handleYearSelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "tags"}
|
||||
title={t("library.filters.tags")}
|
||||
options={tvTagFilterOptions}
|
||||
onSelect={handleTagSelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "sortBy"}
|
||||
title={t("library.filters.sort_by")}
|
||||
options={tvSortByOptions}
|
||||
onSelect={(value) => setSortBy([value])}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "sortOrder"}
|
||||
title={t("library.filters.sort_order")}
|
||||
options={tvSortOrderOptions}
|
||||
onSelect={(value) => setSortOrder([value])}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "filterBy"}
|
||||
title={t("library.filters.filter_by")}
|
||||
options={tvFilterByOptions}
|
||||
onSelect={handleFilterBySelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,109 +1,11 @@
|
||||
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";
|
||||
import { Platform } from "react-native";
|
||||
import { Libraries } from "@/components/library/Libraries";
|
||||
import { TVLibraries } from "@/components/library/TVLibraries";
|
||||
|
||||
export default function index() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { settings } = useSettings();
|
||||
export default function LibrariesPage() {
|
||||
if (Platform.isTV) {
|
||||
return <TVLibraries />;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
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' />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
return <Libraries />;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Image } from "expo-image";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { orderBy, uniqBy } from "lodash";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -22,9 +23,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Input } from "@/components/common/Input";
|
||||
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 {
|
||||
JellyseerrSearchSort,
|
||||
@@ -36,11 +39,19 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type {
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
@@ -57,6 +68,9 @@ const exampleSearches = [
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const from = (segments as string[])[2] || "(search)";
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
@@ -197,9 +211,7 @@ export default function search() {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = `${
|
||||
settings.marlinServerUrl
|
||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
@@ -438,6 +450,179 @@ export default function search() {
|
||||
return 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 (
|
||||
<ScrollView
|
||||
keyboardDismissMode='on-drag'
|
||||
@@ -448,26 +633,6 @@ export default function search() {
|
||||
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
|
||||
className='flex flex-col'
|
||||
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 />;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
@@ -16,9 +17,18 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import {
|
||||
getItemNavigation,
|
||||
TouchableItemRouter,
|
||||
} from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
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 useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import {
|
||||
useDeleteWatchlist,
|
||||
@@ -31,6 +41,20 @@ import {
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const TV_ITEM_GAP = 16;
|
||||
const TV_SCALE_PADDING = 20;
|
||||
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default function WatchlistDetailScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -46,6 +70,14 @@ export default function WatchlistDetailScreen() {
|
||||
: undefined;
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (Platform.isTV) {
|
||||
// Calculate columns based on TV poster width + gap
|
||||
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||
);
|
||||
}
|
||||
if (screenWidth < 300) return 2;
|
||||
if (screenWidth < 500) return 3;
|
||||
if (screenWidth < 800) return 5;
|
||||
@@ -152,6 +184,37 @@ export default function WatchlistDetailScreen() {
|
||||
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||
);
|
||||
|
||||
const renderTVItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||
const handlePress = () => {
|
||||
const navigation = getItemNavigation(item, "(watchlists)");
|
||||
router.push(navigation as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginRight: TV_ITEM_GAP,
|
||||
marginBottom: TV_ITEM_GAP,
|
||||
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} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
@@ -277,13 +340,14 @@ export default function WatchlistDetailScreen() {
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
|
||||
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
|
||||
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
renderItem={renderItem}
|
||||
renderItem={Platform.isTV ? renderTVItem : renderItem}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||
|
||||
export default function WatchlistsLayout() {
|
||||
@@ -39,7 +40,7 @@ export default function WatchlistsLayout() {
|
||||
name='[watchlistId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -50,7 +51,7 @@ export default function WatchlistsLayout() {
|
||||
options={{
|
||||
title: t("watchlists.create_title"),
|
||||
presentation: "modal",
|
||||
headerShown: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerStyle: { backgroundColor: "#171717" },
|
||||
headerTintColor: "white",
|
||||
contentStyle: { backgroundColor: "#171717" },
|
||||
@@ -61,7 +62,7 @@ export default function WatchlistsLayout() {
|
||||
options={{
|
||||
title: t("watchlists.edit_title"),
|
||||
presentation: "modal",
|
||||
headerShown: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerStyle: { backgroundColor: "#171717" },
|
||||
headerTintColor: "white",
|
||||
contentStyle: { backgroundColor: "#171717" },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||
import type {
|
||||
StreamystatsWatchlistAllowedItemType,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
||||
import type {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,6 +7,7 @@ import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
useStreamystatsEnabled,
|
||||
useWatchlistsQuery,
|
||||
|
||||
@@ -11,12 +11,18 @@ import { withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
// 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();
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
@@ -117,6 +123,17 @@ export default function TabLayout() {
|
||||
: (_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>
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
@@ -38,6 +37,7 @@ import { Text } from "@/components/common/Text";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMusicCast } from "@/hooks/useMusicCast";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type BaseItemDto,
|
||||
type MediaSourceInfo,
|
||||
type MediaStream,
|
||||
PlaybackOrder,
|
||||
PlaybackProgressInfo,
|
||||
RepeatMode,
|
||||
@@ -10,23 +11,24 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, useWindowDimensions, View } from "react-native";
|
||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||
import {
|
||||
PlaybackSpeedScope,
|
||||
updatePlaybackSpeedSettings,
|
||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
@@ -44,7 +46,11 @@ import {
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
getMpvAudioId,
|
||||
@@ -60,6 +66,7 @@ export default function page() {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
@@ -78,6 +85,13 @@ export default function page() {
|
||||
const [tracksReady, setTracksReady] = useState(false);
|
||||
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
|
||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
||||
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 isSeeking = useSharedValue(false);
|
||||
@@ -87,10 +101,9 @@ export default function page() {
|
||||
: require("react-native-volume-manager");
|
||||
|
||||
const downloadUtils = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => downloadUtils.getDownloadedItems(),
|
||||
[downloadUtils.getDownloadedItems],
|
||||
);
|
||||
// Call directly instead of useMemo - the function reference doesn't change
|
||||
// when data updates, only when the provider initializes
|
||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
@@ -109,7 +122,7 @@ export default function page() {
|
||||
bitrateValue: bitrateValueStr,
|
||||
offline: offlineStr,
|
||||
playbackPosition: playbackPositionFromUrl,
|
||||
} = useGlobalSearchParams<{
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
@@ -122,7 +135,6 @@ export default function page() {
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager({ isOffline: offline });
|
||||
|
||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||
// This is computed after downloadedItem is available, see audioIndexResolved below
|
||||
@@ -145,6 +157,10 @@ export default function page() {
|
||||
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
|
||||
const audioIndex = useMemo(() => {
|
||||
if (audioIndexFromUrl !== undefined) {
|
||||
@@ -156,6 +172,17 @@ export default function page() {
|
||||
return undefined;
|
||||
}, [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
|
||||
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
||||
item,
|
||||
@@ -243,15 +270,18 @@ export default function page() {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Ref to store the stream fetch function for refreshing subtitle tracks
|
||||
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStreamData = async () => {
|
||||
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||
setStreamStatus({ isLoading: true, isError: false });
|
||||
try {
|
||||
// Don't attempt to fetch stream data if item is not available
|
||||
if (!item?.Id) {
|
||||
console.log("Item not loaded yet, skipping stream data fetch");
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
@@ -269,12 +299,12 @@ export default function page() {
|
||||
if (!api) {
|
||||
console.warn("API not available for streaming");
|
||||
setStreamStatus({ isLoading: false, isError: true });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
if (!user?.Id) {
|
||||
console.warn("User not authenticated for streaming");
|
||||
setStreamStatus({ isLoading: false, isError: true });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate start ticks directly from item to avoid stale closure
|
||||
@@ -293,7 +323,7 @@ export default function page() {
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
});
|
||||
if (!res) return;
|
||||
if (!res) return null;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
@@ -301,17 +331,22 @@ export default function page() {
|
||||
t("player.error"),
|
||||
t("player.failed_to_get_stream_url"),
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
result = { mediaSource, sessionId, url };
|
||||
}
|
||||
setStream(result);
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stream:", error);
|
||||
setStreamStatus({ isLoading: false, isError: true });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Store the fetch function in ref for use by refresh handler
|
||||
refetchStreamRef.current = fetchStreamData;
|
||||
fetchStreamData();
|
||||
}, [
|
||||
itemId,
|
||||
@@ -445,7 +480,7 @@ export default function page() {
|
||||
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
|
||||
if (isSeeking.get() || isPlaybackStopped) return;
|
||||
|
||||
const { position } = data.nativeEvent;
|
||||
const { position, cacheSeconds } = data.nativeEvent;
|
||||
// MPV reports position in seconds, convert to ms
|
||||
const currentTime = position * 1000;
|
||||
|
||||
@@ -455,6 +490,12 @@ export default function page() {
|
||||
|
||||
progress.set(currentTime);
|
||||
|
||||
// Update cache progress (current position + buffered seconds ahead)
|
||||
if (cacheSeconds !== undefined && cacheSeconds > 0) {
|
||||
const cacheEnd = currentTime + cacheSeconds * 1000;
|
||||
cacheProgress.set(cacheEnd);
|
||||
}
|
||||
|
||||
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||
const now = Date.now();
|
||||
const shouldUpdateUrl = wasJustSeeking.get();
|
||||
@@ -527,7 +568,11 @@ export default function page() {
|
||||
subtitleIndex,
|
||||
isTranscoding,
|
||||
);
|
||||
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
|
||||
const initialAudioId = getMpvAudioId(
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
isTranscoding,
|
||||
);
|
||||
|
||||
// Calculate start position directly here to avoid timing issues
|
||||
const startTicks = playbackPositionFromUrl
|
||||
@@ -677,8 +722,8 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
setIsBuffering(true);
|
||||
if (isLoading !== undefined) {
|
||||
setIsBuffering(isLoading);
|
||||
}
|
||||
},
|
||||
[playbackManager, item?.Id, progress],
|
||||
@@ -723,6 +768,108 @@ export default function page() {
|
||||
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
|
||||
const handleToggleTechnicalInfo = useCallback(() => {
|
||||
setShowTechnicalInfo((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Get technical info from the player
|
||||
const getTechnicalInfo = useCallback(async () => {
|
||||
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
||||
}, []);
|
||||
|
||||
// Determine play method based on stream URL and media source
|
||||
const playMethod = useMemo<
|
||||
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
||||
>(() => {
|
||||
if (!stream?.url) return undefined;
|
||||
|
||||
// Check if transcoding (m3u8 playlist or TranscodingUrl present)
|
||||
if (stream.url.includes("m3u8") || stream.mediaSource?.TranscodingUrl) {
|
||||
return "Transcode";
|
||||
}
|
||||
|
||||
// Check if direct play (no container remuxing needed)
|
||||
// Direct play means the file is being served as-is
|
||||
if (stream.url.includes("/Videos/") && stream.url.includes("/stream")) {
|
||||
return "DirectStream";
|
||||
}
|
||||
|
||||
// Default to direct play if we're not transcoding
|
||||
return "DirectPlay";
|
||||
}, [stream?.url, stream?.mediaSource?.TranscodingUrl]);
|
||||
|
||||
// Extract transcode reasons from the TranscodingUrl
|
||||
const transcodeReasons = useMemo<string[]>(() => {
|
||||
const transcodingUrl = stream?.mediaSource?.TranscodingUrl;
|
||||
if (!transcodingUrl) return [];
|
||||
|
||||
try {
|
||||
// Parse the TranscodeReasons parameter from the URL
|
||||
const url = new URL(transcodingUrl, "http://localhost");
|
||||
const reasons = url.searchParams.get("TranscodeReasons");
|
||||
if (reasons) {
|
||||
return reasons.split(",").filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, try regex fallback
|
||||
const match = transcodingUrl.match(/TranscodeReasons=([^&]+)/);
|
||||
if (match) {
|
||||
return match[1].split(",").filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}, [stream?.mediaSource?.TranscodingUrl]);
|
||||
|
||||
const handleZoomToggle = useCallback(async () => {
|
||||
const newZoomState = !isZoomedToFill;
|
||||
await videoRef.current?.setZoomedToFill?.(newZoomState);
|
||||
@@ -759,6 +906,109 @@ export default function page() {
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!isVideoLoaded || !videoRef.current) return;
|
||||
@@ -833,99 +1083,138 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<PlayerProvider
|
||||
playerRef={videoRef}
|
||||
item={item}
|
||||
mediaSource={stream?.mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
tracksReady={tracksReady}
|
||||
offline={offline}
|
||||
downloadedItem={downloadedItem}
|
||||
>
|
||||
<VideoProvider>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<OfflineModeProvider isOffline={offline}>
|
||||
<PlayerProvider
|
||||
playerRef={videoRef}
|
||||
item={item}
|
||||
mediaSource={stream?.mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
tracksReady={tracksReady}
|
||||
downloadedItem={downloadedItem}
|
||||
>
|
||||
<VideoProvider>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<MpvPlayerView
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onTracksReady={() => {
|
||||
setTracksReady(true);
|
||||
}}
|
||||
/>
|
||||
{!hasPlaybackStarted && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
>
|
||||
<MpvPlayerView
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
onTracksReady={() => {
|
||||
setTracksReady(true);
|
||||
}}
|
||||
/>
|
||||
{!hasPlaybackStarted && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{isMounted === true &&
|
||||
item &&
|
||||
!isPipMode &&
|
||||
(Platform.isTV ? (
|
||||
<TVControls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
play={play}
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
audioIndex={currentAudioIndex}
|
||||
subtitleIndex={currentSubtitleIndex}
|
||||
onAudioIndexChange={handleAudioIndexChange}
|
||||
onSubtitleIndexChange={handleSubtitleIndexChange}
|
||||
previousItem={previousItem}
|
||||
nextItem={nextItem}
|
||||
goToPreviousItem={goToPreviousItem}
|
||||
goToNextItem={goToNextItem}
|
||||
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
||||
addSubtitleFile={addSubtitleFile}
|
||||
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>
|
||||
{isMounted === true && item && !isPipMode && (
|
||||
<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}
|
||||
offline={offline}
|
||||
aspectRatio={aspectRatio}
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onZoomToggle={handleZoomToggle}
|
||||
api={api}
|
||||
downloadedFiles={downloadedFiles}
|
||||
playbackSpeed={currentPlaybackSpeed}
|
||||
setPlaybackSpeed={handleSetPlaybackSpeed}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</VideoProvider>
|
||||
</PlayerProvider>
|
||||
</VideoProvider>
|
||||
</PlayerProvider>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
489
app/(auth)/tv-request-modal.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
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 { TVTypography } 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 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}>{t("jellyseerr.advanced")}</Text>
|
||||
<Text style={styles.subtitle}>{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}>
|
||||
{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: {
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: TVTypography.callout,
|
||||
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: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
443
app/(auth)/tv-season-select-modal.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
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 { TVTypography } 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 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}>{t("jellyseerr.select_seasons")}</Text>
|
||||
<Text style={styles.subtitle}>{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}>
|
||||
{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: {
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: TVTypography.callout,
|
||||
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: {
|
||||
fontSize: TVTypography.callout,
|
||||
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: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
188
app/(auth)/tv-series-season-modal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
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 { TVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
export default function TVSeriesSeasonModalPage() {
|
||||
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}>{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: {
|
||||
fontSize: TVTypography.callout,
|
||||
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
209
app/_layout.tsx
@@ -10,6 +10,7 @@ import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
@@ -47,7 +49,7 @@ import type {
|
||||
NotificationResponse,
|
||||
} from "expo-notifications/build/Notifications.types";
|
||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||
import { router, Stack, useSegments } from "expo-router";
|
||||
import { Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
@@ -56,8 +58,9 @@ import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "@/utils/store";
|
||||
import { store as jotaiStore, store } from "@/utils/store";
|
||||
import "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
@@ -80,14 +83,9 @@ SplashScreen.setOptions({
|
||||
fade: true,
|
||||
});
|
||||
|
||||
function redirect(notification: typeof Notifications.Notification) {
|
||||
const url = notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
function useNotificationObserver() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -98,14 +96,17 @@ function useNotificationObserver() {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
const url = response?.notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
}, [router]);
|
||||
}
|
||||
|
||||
if (!Platform.isTV) {
|
||||
@@ -178,7 +179,7 @@ export default function RootLayout() {
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<JotaiProvider>
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<ActionSheetProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Layout />
|
||||
@@ -230,6 +231,7 @@ function Layout() {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const _segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(
|
||||
@@ -322,9 +324,6 @@ function Layout() {
|
||||
responseListener.current =
|
||||
Notifications?.addNotificationResponseReceivedListener(
|
||||
(response: NotificationResponse) => {
|
||||
// redirect if internal notification
|
||||
redirect(response?.notification);
|
||||
|
||||
// Currently the notifications supported by the plugin will send data for deep links.
|
||||
const { title, data } = response.notification.request.content;
|
||||
writeInfoLog(`Notification ${title} opened`, data);
|
||||
@@ -384,77 +383,119 @@ function Layout() {
|
||||
}}
|
||||
>
|
||||
<JellyfinProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<ServerUrlProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/now-playing'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/now-playing'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<GlobalModal />
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
</JellyfinProvider>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
|
||||
662
app/login.tsx
@@ -1,659 +1,13 @@
|
||||
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";
|
||||
import { Platform } from "react-native";
|
||||
import { Login } from "@/components/login/Login";
|
||||
import { TVLogin } from "@/components/login/TVLogin";
|
||||
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
});
|
||||
|
||||
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;
|
||||
const LoginPage: React.FC = () => {
|
||||
if (Platform.isTV) {
|
||||
return <TVLogin />;
|
||||
}
|
||||
/**
|
||||
* 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 () => {
|
||||
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>
|
||||
);
|
||||
return <Login />;
|
||||
};
|
||||
|
||||
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 |
@@ -1,4 +1,7 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -13,16 +16,9 @@ export const Badge: React.FC<Props> = ({
|
||||
variant = "purple",
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
className={`
|
||||
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
|
||||
${variant === "purple" && "bg-purple-600"}
|
||||
${variant === "gray" && "bg-neutral-800"}
|
||||
`}
|
||||
>
|
||||
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
|
||||
const content = (
|
||||
<View style={styles.content}>
|
||||
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||
<Text
|
||||
className={`
|
||||
text-xs
|
||||
@@ -33,4 +29,104 @@ export const Badge: React.FC<Props> = ({
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<View {...props} style={[styles.container, props.style]}>
|
||||
<GlassEffectView style={{ borderRadius: 100 }}>
|
||||
{content}
|
||||
</GlassEffectView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 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: TVTypography.callout,
|
||||
color: "#E5E7EB",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</BlurView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
style={[
|
||||
{
|
||||
borderRadius: 4,
|
||||
padding: 4,
|
||||
paddingHorizontal: 6,
|
||||
flexShrink: 1,
|
||||
flexGrow: 0,
|
||||
alignSelf: "flex-start",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: variant === "purple" ? "#9333ea" : "#262626",
|
||||
},
|
||||
props.style,
|
||||
]}
|
||||
>
|
||||
{iconLeft && <View style={{ marginRight: 4 }}>{iconLeft}</View>}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: "hidden",
|
||||
alignSelf: "flex-start",
|
||||
flexShrink: 1,
|
||||
flexGrow: 0,
|
||||
},
|
||||
content: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 50,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
iconLeft: {
|
||||
marginRight: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { fontSize, size } from "react-native-responsive-sizes";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
@@ -122,7 +123,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.08);
|
||||
animateTo(1.03);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
@@ -132,19 +133,23 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
shadowColor: "#a855f7",
|
||||
shadowColor: color === "black" ? "#ffffff" : "#a855f7",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.9 : 0,
|
||||
shadowRadius: focused ? 18 : 0,
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 10 : 0,
|
||||
elevation: focused ? 12 : 0, // Android glow
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`rounded-2xl py-5 items-center justify-center
|
||||
className={`rounded-2xl items-center justify-center
|
||||
${colorClasses}
|
||||
${className}`}
|
||||
style={{ paddingVertical: size(50) }}
|
||||
>
|
||||
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||
<Text
|
||||
className={`${textColorClass} font-bold`}
|
||||
style={{ fontSize: fontSize(10) }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
128
components/ContinueWatchingPoster.tv.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
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 { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ProgressBar } from "./common/ProgressBar";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
|
||||
export const TV_LANDSCAPE_WIDTH = 340;
|
||||
|
||||
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=600&quality=80`;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||
}
|
||||
if (item.Type === "Movie") {
|
||||
if (item.ImageTags?.Thumb) {
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||
}
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||
}
|
||||
if (item.Type === "Program") {
|
||||
if (item.ImageTags?.Thumb) {
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||
}
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||
}
|
||||
|
||||
if (item.ImageTags?.Thumb) {
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||
}
|
||||
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||
}, [api, item, useEpisodePoster]);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: TV_LANDSCAPE_WIDTH,
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -9,13 +9,14 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type Href, router } from "expo-router";
|
||||
import { type Href } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -62,6 +63,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, _setQueue] = useAtom(queueAtom);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||
|
||||
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||
@@ -170,9 +172,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
pathname: "/series/[id]",
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
id: firstItem.SeriesId!,
|
||||
seasonIndex: firstItem.ParentIndexNumber?.toString(),
|
||||
offline: "true",
|
||||
},
|
||||
} as Href),
|
||||
);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// GenreTags.tsx
|
||||
import { BlurView } from "expo-blur";
|
||||
import type React from "react";
|
||||
import {
|
||||
Platform,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface TagProps {
|
||||
@@ -20,6 +25,49 @@ export const Tag: React.FC<
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
} & ViewProps
|
||||
> = ({ text, textClass, textStyle, ...props }) => {
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<View>
|
||||
<GlassEffectView style={styles.glass}>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
}}
|
||||
>
|
||||
<Text>{text}</Text>
|
||||
</View>
|
||||
</GlassEffectView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 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: TVTypography.callout, color: "#E5E7EB" }}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</BlurView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
||||
<Text className={textClass} style={textStyle}>
|
||||
@@ -29,6 +77,16 @@ export const Tag: React.FC<
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: "hidden",
|
||||
borderRadius: 50,
|
||||
},
|
||||
glass: {
|
||||
borderRadius: 50,
|
||||
},
|
||||
});
|
||||
|
||||
export const Tags: React.FC<
|
||||
TagProps & { tagProps?: ViewProps } & ViewProps
|
||||
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
|
||||
@@ -36,7 +94,8 @@ export const Tags: React.FC<
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
{tags.map((tag, idx) => (
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export interface IntroSheetRef {
|
||||
@@ -24,6 +24,7 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
present: () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
|
||||
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
@@ -26,6 +25,7 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
@@ -35,6 +35,9 @@ import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
const ItemContentTV = Platform.isTV
|
||||
? require("./ItemContent.tv").ItemContentTV
|
||||
: null;
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
@@ -44,234 +47,243 @@ export type SelectedOptions = {
|
||||
};
|
||||
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
item?: BaseItemDto | null;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, isOffline, itemWithSources }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
// Mobile-specific implementation
|
||||
const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
item,
|
||||
itemWithSources,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
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 [headerHeight, setHeaderHeight] = useState(350);
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
// Use itemWithSources for play settings since it has MediaSources data
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||
// Use itemWithSources for play settings since it has MediaSources data
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
|
||||
const onLogoLoad = React.useCallback(() => {
|
||||
setLoadingLogo(false);
|
||||
}, []);
|
||||
const onLogoLoad = React.useCallback(() => {
|
||||
setLoadingLogo(false);
|
||||
}, []);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
]);
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV && itemWithSources) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item &&
|
||||
(Platform.OS === "ios" ? (
|
||||
<View className='flex flex-row items-center pl-2'>
|
||||
<Chromecast.Chromecast width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV && itemWithSources) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item &&
|
||||
(Platform.OS === "ios" ? (
|
||||
<View className='flex flex-row items-center pl-2'>
|
||||
<Chromecast.Chromecast width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center'>
|
||||
{!Platform.isTV && (
|
||||
<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' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<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' />
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator &&
|
||||
!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={
|
||||
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 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 &&
|
||||
!settings.hideRemoteSessionButton && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||
<PlayButton
|
||||
<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>
|
||||
}
|
||||
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}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={itemWithSources}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<View className='w-1' />
|
||||
{!isOffline && (
|
||||
<MediaSourceButton
|
||||
selectedOptions={selectedOptions}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={itemWithSources}
|
||||
colors={itemColors}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
loading={loading}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
{!isOffline &&
|
||||
selectedOptions.mediaSource?.MediaStreams &&
|
||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
|
||||
{!isOffline &&
|
||||
selectedOptions.mediaSource?.MediaStreams &&
|
||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{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" && (
|
||||
<>
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
<CurrentSeries item={item} className='mb-2' />
|
||||
)}
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
<ItemPeopleSections item={item} isOffline={isOffline} />
|
||||
// Memoize the mobile component
|
||||
const MemoizedItemContentMobile = React.memo(ItemContentMobile);
|
||||
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
// Exported component that renders TV or mobile version based on platform
|
||||
export const ItemContent: React.FC<ItemContentProps> = (props) => {
|
||||
if (Platform.isTV && ItemContentTV) {
|
||||
return <ItemContentTV {...props} />;
|
||||
}
|
||||
return <MemoizedItemContentMobile {...props} />;
|
||||
};
|
||||
|
||||
782
components/ItemContent.tv.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { 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, TVFocusGuideView, 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 { GenreTags } from "@/components/GenreTags";
|
||||
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 { TVTypography } 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 [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 });
|
||||
|
||||
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]);
|
||||
|
||||
// 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]);
|
||||
|
||||
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 - 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,
|
||||
}}
|
||||
>
|
||||
<ItemImage
|
||||
variant='Primary'
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Right 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: TVTypography.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: TVTypography.title,
|
||||
color: "#FFFFFF",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.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: TVTypography.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: TVTypography.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>
|
||||
|
||||
{/* Focus guide to direct navigation from options to cast list */}
|
||||
{fullCast.length > 0 && firstActorCardRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[firstActorCardRef]}
|
||||
style={{ height: 1, width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress bar (if partially watched) */}
|
||||
{hasProgress && item.RunTimeTicks != null && (
|
||||
<TVProgressBar
|
||||
progress={
|
||||
(item.UserData?.PlaybackPositionTicks || 0) /
|
||||
item.RunTimeTicks
|
||||
}
|
||||
fillColor='#FFFFFF'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Additional info section */}
|
||||
<View style={{ marginTop: 40 }}>
|
||||
{/* Cast & Crew (text version) */}
|
||||
<TVCastCrewText
|
||||
director={director}
|
||||
cast={cast}
|
||||
hideCast={showVisualCast}
|
||||
/>
|
||||
|
||||
{/* Technical details */}
|
||||
{selectedOptions.mediaSource?.MediaStreams &&
|
||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||
<TVTechnicalDetails
|
||||
mediaStreams={selectedOptions.mediaSource.MediaStreams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
||||
{showVisualCast && (
|
||||
<TVCastSection
|
||||
cast={fullCast}
|
||||
apiBasePath={api?.basePath}
|
||||
onActorPress={handleActorPress}
|
||||
firstActorRefSetter={setFirstActorCardRef}
|
||||
upwardFocusDestination={lastOptionButtonRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* From this Series - Episode only */}
|
||||
<TVSeriesNavigation
|
||||
item={item}
|
||||
seriesImageUrl={seriesImageUrl}
|
||||
seasonImageUrl={seasonImageUrl}
|
||||
onSeriesPress={handleSeriesPress}
|
||||
onSeasonPress={handleSeasonPress}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files)
|
||||
export const ItemContent = ItemContentTV;
|
||||
160
components/ItemContentSkeleton.tv.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
import { Dimensions, View } from "react-native";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
|
||||
export const ItemContentSkeletonTV: React.FC = () => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
paddingTop: 180,
|
||||
paddingHorizontal: 160,
|
||||
}}
|
||||
>
|
||||
{/* Left side - Poster placeholder */}
|
||||
<View
|
||||
style={{
|
||||
width: SCREEN_WIDTH * 0.22,
|
||||
marginRight: 50,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Right side - Content placeholders */}
|
||||
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||
{/* Logo/Title placeholder */}
|
||||
<View
|
||||
style={{
|
||||
height: 80,
|
||||
width: "60%",
|
||||
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>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -77,7 +77,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
|
||||
<View>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.subtitles")}
|
||||
{t("item_card.subtitles.label")}
|
||||
</Text>
|
||||
<SubtitleStreamInfo
|
||||
subtitleStreams={
|
||||
|
||||
@@ -142,7 +142,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
}));
|
||||
|
||||
groups.push({
|
||||
title: t("item_card.subtitles"),
|
||||
title: t("item_card.subtitles.label"),
|
||||
options: [noneOption, ...subtitleOptions],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,14 @@ export type ToggleOption = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type Option = RadioOption | ToggleOption;
|
||||
export type ActionOption = {
|
||||
type: "action";
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type Option = RadioOption | ToggleOption | ActionOption;
|
||||
|
||||
// Option group structure
|
||||
export type OptionGroup = {
|
||||
@@ -64,7 +71,10 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
isLast,
|
||||
}) => {
|
||||
const isToggle = option.type === "toggle";
|
||||
const handlePress = isToggle ? option.onToggle : option.onPress;
|
||||
const isAction = option.type === "action";
|
||||
const handlePress = isToggle
|
||||
? option.onToggle
|
||||
: (option as RadioOption | ActionOption).onPress;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -76,7 +86,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||
{isToggle ? (
|
||||
<ToggleSwitch value={option.value} />
|
||||
) : option.selected ? (
|
||||
) : isAction ? null : (option as RadioOption).selected ? (
|
||||
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||
) : (
|
||||
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||
@@ -150,6 +160,15 @@ const BottomSheetContent: React.FC<{
|
||||
},
|
||||
};
|
||||
}
|
||||
if (option.type === "action") {
|
||||
return {
|
||||
...option,
|
||||
onPress: () => {
|
||||
option.onPress();
|
||||
onClose?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
}));
|
||||
@@ -225,6 +244,9 @@ const PlatformDropdownComponent = ({
|
||||
const toggleOptions = group.options.filter(
|
||||
(opt) => opt.type === "toggle",
|
||||
) as ToggleOption[];
|
||||
const actionOptions = group.options.filter(
|
||||
(opt) => opt.type === "action",
|
||||
) as ActionOption[];
|
||||
|
||||
const items = [];
|
||||
|
||||
@@ -291,6 +313,21 @@ const PlatformDropdownComponent = ({
|
||||
);
|
||||
});
|
||||
|
||||
// Add Buttons for action options (no icon)
|
||||
actionOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`action-${groupIndex}-${optionIndex}`}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Button>,
|
||||
);
|
||||
});
|
||||
|
||||
return items;
|
||||
})}
|
||||
</ContextMenu.Items>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -24,11 +23,13 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
@@ -44,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent";
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
colors?: ThemeColors;
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
colors,
|
||||
}: Props) => {
|
||||
const isOffline = useOfflineMode();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
@@ -300,6 +300,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
// Check if item is downloaded
|
||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||
|
||||
// If already in offline mode, play downloaded file directly
|
||||
if (isOffline && downloadedItem) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// If online but file is downloaded, ask user which version to play
|
||||
if (downloadedItem) {
|
||||
if (Platform.OS === "android") {
|
||||
// Show bottom sheet for Android
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
@@ -14,6 +13,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
|
||||
@@ -7,7 +7,6 @@ import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
isOffline?: boolean;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
const trigger = (
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.subtitles")}
|
||||
{t("item_card.subtitles.label")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
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
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.subtitles")}
|
||||
title={t("item_card.subtitles.label")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
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 (
|
||||
<>
|
||||
{item.UserData?.Played === false &&
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -26,6 +25,7 @@ import Animated, {
|
||||
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";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
interface Props extends BlurViewProps {
|
||||
background?: "blur" | "transparent";
|
||||
|
||||
@@ -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 {
|
||||
Animated,
|
||||
Easing,
|
||||
Platform,
|
||||
Pressable,
|
||||
TextInput,
|
||||
type TextInputProps,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
interface InputProps extends TextInputProps {
|
||||
extraClassName?: string; // new prop for additional classes
|
||||
extraClassName?: string;
|
||||
}
|
||||
|
||||
export function Input(props: InputProps) {
|
||||
const { style, extraClassName = "", ...otherProps } = props;
|
||||
const inputRef = React.useRef<TextInput>(null);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
return Platform.isTV ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => inputRef?.current?.focus?.()}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
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"}
|
||||
text-white ${extraClassName}
|
||||
`}
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
backgroundColor: isFocused ? "#ffffff88" : "#8f8d8d88",
|
||||
},
|
||||
]}
|
||||
placeholderTextColor={"#ffffffff"}
|
||||
clearButtonMode='while-editing'
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
{...otherProps}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
const animateFocus = (focused: boolean) => {
|
||||
Animated.timing(scale, {
|
||||
toValue: focused ? 1.02 : 1,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
animateFocus(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
};
|
||||
|
||||
if (Platform.isTV) {
|
||||
const containerStyle = {
|
||||
height: 48,
|
||||
borderRadius: 50,
|
||||
borderWidth: isFocused ? 1.5 : 1,
|
||||
borderColor: isFocused
|
||||
? "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
|
||||
ref={inputRef}
|
||||
className='p-4 rounded-xl bg-neutral-900'
|
||||
className={`p-4 rounded-xl bg-neutral-900 ${extraClassName}`}
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
interface ProgressBarProps {
|
||||
item: BaseItemDto;
|
||||
@@ -39,8 +39,9 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
||||
<View
|
||||
style={{
|
||||
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"}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ export function Text(props: TextProps) {
|
||||
if (Platform.isTV)
|
||||
return (
|
||||
<RNText
|
||||
allowFontScaling={false}
|
||||
allowFontScaling={true}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
@@ -134,26 +136,20 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { deleteFile } = useDownload();
|
||||
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
// For offline mode, we still need to use query params
|
||||
if (isOffline) {
|
||||
const url = `${itemRouter(item, from)}&offline=true`;
|
||||
router.push(url as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Force music libraries to navigate via the explicit string route.
|
||||
// This avoids losing the dynamic [libraryId] param when going through a nested navigator.
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
@@ -163,7 +159,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as any);
|
||||
}, [from, isOffline, item, router]);
|
||||
}, [from, item, router]);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
if (
|
||||
@@ -179,14 +175,19 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
"Mark as Played",
|
||||
"Mark as Not Played",
|
||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||
...(isOffline ? ["Delete Download"] : []),
|
||||
"Cancel",
|
||||
];
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const destructiveButtonIndex = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
@@ -195,6 +196,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
await markAsPlayedStatus(false);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite();
|
||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -203,6 +206,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
]);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||
|
||||
@@ -61,7 +61,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
return (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
isOffline={true}
|
||||
onLongPress={showActionSheet}
|
||||
className='flex flex-col mb-4'
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item}>
|
||||
{base64Image ? (
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<Image
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -14,6 +14,7 @@ import { Text } from "../common/Text";
|
||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { deleteItems } = useDownload();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const router = useRouter();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
@@ -46,7 +47,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/series/[id]",
|
||||
params: { id: items[0].SeriesId!, offline: "true" },
|
||||
})
|
||||
}
|
||||
onLongPress={showActionSheet}
|
||||
>
|
||||
{base64Image ? (
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -10,6 +9,7 @@ import { Text, View } from "react-native";
|
||||
// PNG ASSET
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
|
||||
|
||||
|
||||
232
components/home/Favorites.tv.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
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 { TVTypography } 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 { 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: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noDataTitle")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: TVTypography.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>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useNavigation, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -33,6 +33,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
@@ -43,6 +44,9 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
// Conditionally load TV version
|
||||
const HomeTV = Platform.isTV ? require("./Home.tv").Home : null;
|
||||
|
||||
type InfiniteScrollingCollectionListSection = {
|
||||
type: "InfiniteScrollingCollectionList";
|
||||
title?: string;
|
||||
@@ -63,7 +67,7 @@ type MediaListSectionType = {
|
||||
|
||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||
|
||||
export const Home = () => {
|
||||
const HomeMobile = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -686,3 +690,11 @@ export const Home = () => {
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
// Exported component that renders TV or mobile version based on platform
|
||||
export const Home = () => {
|
||||
if (Platform.isTV && HomeTV) {
|
||||
return <HomeTV />;
|
||||
}
|
||||
return <HomeMobile />;
|
||||
};
|
||||
|
||||
756
components/home/Home.tv.tsx
Normal file
@@ -0,0 +1,756 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
|
||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
// Reduced gap since sections have internal padding for scale animations
|
||||
const SECTION_GAP = 10;
|
||||
|
||||
type InfiniteScrollingCollectionListSection = {
|
||||
type: "InfiniteScrollingCollectionList";
|
||||
title?: string;
|
||||
queryKey: (string | undefined | null)[];
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
priority?: 1 | 2;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
type Section = InfiniteScrollingCollectionListSection;
|
||||
|
||||
// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
|
||||
const BACKDROP_DEBOUNCE_MS = 300;
|
||||
|
||||
export const Home = () => {
|
||||
const _router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { settings } = useSettings();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const {
|
||||
isConnected,
|
||||
serverConnected,
|
||||
loading: retryLoading,
|
||||
retryCheck,
|
||||
} = useNetworkStatus();
|
||||
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||
|
||||
// Dynamic backdrop state with debounce
|
||||
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||
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);
|
||||
}
|
||||
// Set new timer to update focused item after debounce delay
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
setFocusedItem(item);
|
||||
}, BACKDROP_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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 () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [backdropUrl]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isError: e1,
|
||||
isLoading: l1,
|
||||
} = useQuery({
|
||||
queryKey: ["home", "userViews", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const userViews = useMemo(
|
||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||
[data, settings?.hiddenLibraries],
|
||||
);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
const allow = ["movies", "tvshows"];
|
||||
return (
|
||||
userViews?.filter(
|
||||
(c) => c.CollectionType && allow.includes(c.CollectionType),
|
||||
) || []
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const createCollectionConfig = useCallback(
|
||||
(
|
||||
title: string,
|
||||
queryKey: string[],
|
||||
includeItemTypes: BaseItemKind[],
|
||||
parentId: string | undefined,
|
||||
pageSize = 10,
|
||||
): InfiniteScrollingCollectionListSection => ({
|
||||
title,
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (!api) return [];
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
pageSize,
|
||||
parentId,
|
||||
}),
|
||||
[api, user?.Id],
|
||||
);
|
||||
|
||||
const defaultSections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||
? []
|
||||
: ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey: string[] = [
|
||||
"home",
|
||||
`recentlyAddedIn${c.CollectionType}`,
|
||||
user.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
title || "",
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
return items.sort((a, b) => {
|
||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (!item.Id || seen.has(item.Id)) return false;
|
||||
seen.add(item.Id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||
? [
|
||||
{
|
||||
title: t("home.continue_and_next_up"),
|
||||
queryKey: ["home", "continueAndNextUp"],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||
getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
}),
|
||||
getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resumeItems = resumeResponse.data.Items || [];
|
||||
const nextUpItems = nextUpResponse.data.Items || [];
|
||||
|
||||
const combined = [...resumeItems, ...nextUpItems];
|
||||
const sorted = sortByRecentActivity(combined);
|
||||
const deduplicated = deduplicateById(sorted);
|
||||
|
||||
return deduplicated.slice(pageParam, pageParam + 10);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
priority: 1,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const ss: Section[] = [
|
||||
...firstSections,
|
||||
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
||||
...(!settings?.streamyStatsMovieRecommendations
|
||||
? [
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList" as const,
|
||||
orientation: "vertical" as const,
|
||||
pageSize: 10,
|
||||
priority: 2 as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return ss;
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
collections,
|
||||
t,
|
||||
createCollectionConfig,
|
||||
settings?.streamyStatsMovieRecommendations,
|
||||
settings.mergeNextUpAndContinueWatching,
|
||||
]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
const ss: Section[] = [];
|
||||
settings.home.sections.forEach((section, index) => {
|
||||
const id = section.title || `section-${index}`;
|
||||
const pageSize = 10;
|
||||
ss.push({
|
||||
title: t(`${id}`),
|
||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (section.items) {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: section.items?.limit || pageSize,
|
||||
recursive: true,
|
||||
includeItemTypes: section.items?.includeItemTypes,
|
||||
sortBy: section.items?.sortBy,
|
||||
sortOrder: section.items?.sortOrder,
|
||||
filters: section.items?.filters,
|
||||
parentId: section.items?.parentId,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: section.nextUp?.limit || pageSize,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: section.nextUp?.enableResumable,
|
||||
enableRewatching: section.nextUp?.enableRewatching,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.latest) {
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 10,
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
}
|
||||
if (section.custom) {
|
||||
const response = await api.get<BaseItemDtoQueryResult>(
|
||||
section.custom.endpoint,
|
||||
{
|
||||
params: {
|
||||
...(section.custom.query || {}),
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
},
|
||||
headers: section.custom.headers || {},
|
||||
},
|
||||
);
|
||||
return response.data.Items || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
pageSize,
|
||||
priority: index < 2 ? 1 : 2,
|
||||
});
|
||||
});
|
||||
return ss;
|
||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||
|
||||
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) {
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
|
||||
if (!isConnected) {
|
||||
title = t("home.no_internet");
|
||||
subtitle = t("home.no_internet_message");
|
||||
} else if (serverConnected === null) {
|
||||
title = t("home.checking_server_connection");
|
||||
subtitle = t("home.checking_server_connection_message");
|
||||
} else if (!serverConnected) {
|
||||
title = t("home.server_unreachable");
|
||||
subtitle = t("home.server_unreachable_message");
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: TVTypography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
<View style={{ marginTop: 24 }}>
|
||||
<Button
|
||||
color='black'
|
||||
onPress={retryCheck}
|
||||
justify='center'
|
||||
className='px-4'
|
||||
iconRight={
|
||||
retryLoading ? null : (
|
||||
<Ionicons name='refresh' size={24} color='white' />
|
||||
)
|
||||
}
|
||||
>
|
||||
{retryLoading ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
) : (
|
||||
t("home.retry")
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (e1)
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("home.oops")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: TVTypography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("home.error_message")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||
{/* Dynamic backdrop 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 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: insets.top + TOP_PADDING,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => {
|
||||
// Render Streamystats sections after Continue Watching and Next Up
|
||||
// When merged, they appear after index 0; otherwise after index 1
|
||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||
? 0
|
||||
: 1;
|
||||
const hasStreamystatsContent =
|
||||
settings.streamyStatsMovieRecommendations ||
|
||||
settings.streamyStatsSeriesRecommendations ||
|
||||
settings.streamyStatsPromotedWatchlists;
|
||||
const streamystatsSections =
|
||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
|
||||
{settings.streamyStatsMovieRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_movies",
|
||||
)}
|
||||
type='Movie'
|
||||
enabled={allHighPriorityLoaded}
|
||||
onItemFocus={handleItemFocus}
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsSeriesRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_series",
|
||||
)}
|
||||
type='Series'
|
||||
enabled={allHighPriorityLoaded}
|
||||
onItemFocus={handleItemFocus}
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsPromotedWatchlists && (
|
||||
<StreamystatsPromotedWatchlists
|
||||
enabled={allHighPriorityLoaded}
|
||||
onItemFocus={handleItemFocus}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
const isHighPriority = section.priority === 1;
|
||||
const isFirstSection = index === 0;
|
||||
return (
|
||||
<View key={index} style={{ gap: SECTION_GAP }}>
|
||||
<InfiniteScrollingCollectionList
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
enabled={isHighPriority || allHighPriorityLoaded}
|
||||
onLoaded={
|
||||
isHighPriority
|
||||
? () => markSectionLoaded(section.queryKey)
|
||||
: undefined
|
||||
}
|
||||
isFirstSection={isFirstSection}
|
||||
onItemFocus={handleItemFocus}
|
||||
parentId={section.parentId}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useNavigation, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -35,6 +35,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
|
||||
488
components/home/InfiniteScrollingCollectionList.tv.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
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 { TVTypography } 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 = 16;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// TV-specific ItemCardText with larger fonts
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
return (
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.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;
|
||||
}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => {
|
||||
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: TVTypography.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 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,
|
||||
refetchOnMount: false,
|
||||
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} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[
|
||||
orientation,
|
||||
isFirstSection,
|
||||
itemWidth,
|
||||
handleItemPress,
|
||||
handleItemFocus,
|
||||
handleItemBlur,
|
||||
],
|
||||
);
|
||||
|
||||
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: TVTypography.body,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{isLoading === false && allItems.length === 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#737373",
|
||||
fontSize: TVTypography.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: TVTypography.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}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Dimensions, View, type ViewProps } from "react-native";
|
||||
@@ -16,6 +16,7 @@ import Carousel, {
|
||||
type ICarouselInstance,
|
||||
Pagination,
|
||||
} from "react-native-reanimated-carousel";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
@@ -21,7 +21,6 @@ interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
scrollY?: number; // For lazy loading
|
||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||
}
|
||||
@@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
isOffline = false,
|
||||
scrollY = 0,
|
||||
enableLazyLoading = false,
|
||||
...props
|
||||
@@ -106,7 +104,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
isOffline={isOffline}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
|
||||
342
components/home/StreamystatsPromotedWatchlists.tv.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
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 { TVTypography } 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;
|
||||
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
return (
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.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 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: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
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} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[handleItemPress, onItemFocus],
|
||||
);
|
||||
|
||||
if (!isLoading && (!items || items.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View style={{ overflow: "visible" }} {...props}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{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: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (!streamyStatsEnabled) return null;
|
||||
if (isError) return null;
|
||||
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
275
components/home/StreamystatsRecommendations.tv.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
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 { TVTypography } 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;
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title: string;
|
||||
type: "Movie" | "Series";
|
||||
limit?: number;
|
||||
enabled?: boolean;
|
||||
onItemFocus?: (item: BaseItemDto) => void;
|
||||
}
|
||||
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
return (
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
title,
|
||||
type,
|
||||
limit = 20,
|
||||
enabled = true,
|
||||
onItemFocus,
|
||||
...props
|
||||
}) => {
|
||||
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: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
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: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
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} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[handleItemPress, onItemFocus],
|
||||
);
|
||||
|
||||
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: TVTypography.body,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -8,17 +8,14 @@ import { InteractionManager, View, type ViewProps } from "react-native";
|
||||
import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
export const ItemPeopleSections: React.FC<Props> = ({
|
||||
item,
|
||||
isOffline,
|
||||
...props
|
||||
}) => {
|
||||
export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
const isOffline = useOfflineMode();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
COMPANY_LOGO_IMAGE_FILTER,
|
||||
@@ -16,6 +17,7 @@ const CompanySlide: React.FC<
|
||||
> = ({ slide, data, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
@@ -13,6 +14,7 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta
|
||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
268
components/jellyseerr/discover/TVDiscoverSlide.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
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 { TVTypography } 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 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: TVTypography.callout,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{year && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.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 { 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: TVTypography.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>
|
||||
);
|
||||
};
|
||||
875
components/jellyseerr/tv/TVJellyseerrPage.tsx
Normal file
@@ -0,0 +1,875 @@
|
||||
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 { TVTypography } 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 { 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: TVTypography.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 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: TVTypography.display,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginTop: 8,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
|
||||
{/* Year */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.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: TVTypography.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: TVTypography.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: TVTypography.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: TVTypography.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: TVTypography.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: TVTypography.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: TVTypography.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: TVTypography.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: TVTypography.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>
|
||||
);
|
||||
};
|
||||
518
components/jellyseerr/tv/TVRequestModal.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
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 { TVTypography } 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 { 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: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.advanced")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.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: TVTypography.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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
85
components/jellyseerr/tv/TVRequestOptionRow.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
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 { TVTypography } 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 { 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: TVTypography.callout,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.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>
|
||||
);
|
||||
};
|
||||
115
components/jellyseerr/tv/TVToggleOptionRow.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
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 { TVTypography } 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 { 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: TVTypography.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,
|
||||
}) => {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.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";
|
||||