Compare commits
187 Commits
remove-opt
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3438e78cab | ||
|
|
67bca1f989 | ||
|
|
c35e97f388 | ||
|
|
bc575c26c1 | ||
|
|
ab526f2c6b | ||
|
|
7d0b3be8c2 | ||
|
|
a384b34402 | ||
|
|
07f535a6e4 | ||
|
|
2bcf52209e | ||
|
|
fb7cee7718 | ||
|
|
2775075187 | ||
|
|
4962f2161f | ||
|
|
25ec9c4348 | ||
|
|
d17414bc93 | ||
|
|
fea3e1449a | ||
|
|
ad1d9b5888 | ||
|
|
3d406314a4 | ||
|
|
e6598f0944 | ||
|
|
f549e8eaed | ||
|
|
dab1c10a03 | ||
|
|
7e2962e539 | ||
|
|
81cf672eb7 | ||
|
|
591d89c19f | ||
|
|
44b7434cdd | ||
|
|
717186e13e | ||
|
|
4afab8d94a | ||
|
|
4601ae20b6 | ||
|
|
1ec887c29e | ||
|
|
85a74a9a6a | ||
|
|
6e85c8d54a | ||
|
|
bf518b4834 | ||
|
|
d78ac2963f | ||
|
|
2818c17e97 | ||
|
|
b87e7a159f | ||
|
|
af2cac0e86 | ||
|
|
28e3060ace | ||
|
|
3814237ac6 | ||
|
|
aed3a8f493 | ||
|
|
0cd74519d4 | ||
|
|
8ecb7c205b | ||
|
|
3827350ffd | ||
|
|
53902aebab | ||
|
|
bf3a37c61c | ||
|
|
2c0a9b6cd9 | ||
|
|
80136f1800 | ||
|
|
01298c9b6d | ||
|
|
4bea01c963 | ||
|
|
94ac458f52 | ||
|
|
409629bb4a | ||
|
|
2ff9625903 | ||
|
|
8dcd4c40f9 | ||
|
|
74114893e5 | ||
|
|
268a6d96de | ||
|
|
2780b902e9 | ||
|
|
6033958158 | ||
|
|
9763c26046 | ||
|
|
05a2627c94 | ||
|
|
62a099e82f | ||
|
|
43ca6e9148 | ||
|
|
1cbb46f0ca | ||
|
|
21f2ceefc3 | ||
|
|
9d6a9decc9 | ||
|
|
246e0af0f6 | ||
|
|
a0dd752d8f | ||
|
|
c5eb7b0c96 | ||
|
|
55c74ab383 | ||
|
|
7fe24369c0 | ||
|
|
111397a306 | ||
|
|
b79b343ce3 | ||
|
|
c029228138 | ||
|
|
d51cf47eb4 | ||
|
|
bbd7854287 | ||
|
|
44caf4b1ff | ||
|
|
92c70fadd1 | ||
|
|
f637367b82 | ||
|
|
715764cef8 | ||
|
|
36d6686258 | ||
|
|
dca7cc99f2 | ||
|
|
875a017e8c | ||
|
|
0c6c20f563 | ||
|
|
2c9906377d | ||
|
|
d5f7a18fe5 | ||
|
|
4606b9718e | ||
|
|
c2d61654b0 | ||
|
|
2c6938c739 | ||
|
|
1f454c0f12 | ||
|
|
c215fda973 | ||
|
|
a852e2e769 | ||
|
|
29873e08d7 | ||
|
|
5ce5cc2d99 | ||
|
|
ae5a71ff29 | ||
|
|
0e3e8b8016 | ||
|
|
d07a521f60 | ||
|
|
566ff485fb | ||
|
|
3a4042efd5 | ||
|
|
fb9b4b6f2d | ||
|
|
1b80db678e | ||
|
|
093fcc6187 | ||
|
|
26e8489384 | ||
|
|
02a65059b9 | ||
|
|
be2fd53f31 | ||
|
|
be92b5d75e | ||
|
|
3f882ecade | ||
|
|
4b7007386f | ||
|
|
d2790f4997 | ||
|
|
096670a0c3 | ||
|
|
aa6b441dd1 | ||
|
|
d8512897ad | ||
|
|
11b6f16cd3 | ||
|
|
506d8b14dc | ||
|
|
a8acdf4299 | ||
|
|
2a9f4c2885 | ||
|
|
0353a718f3 | ||
|
|
e3b4952c60 | ||
|
|
5f44540b6f | ||
|
|
4705c9f4f9 | ||
|
|
2b36d4bc76 | ||
|
|
f4445c4152 | ||
|
|
16a236393d | ||
|
|
eeb4ef3008 | ||
|
|
a173db9180 | ||
|
|
a8c07a31d3 | ||
|
|
493df28b8d | ||
|
|
749473c1e8 | ||
|
|
f8d1fad6d5 | ||
|
|
81af2afef8 | ||
|
|
9ef79ef364 | ||
|
|
83babc2687 | ||
|
|
f9a3a1f9f6 | ||
|
|
0f076d197f | ||
|
|
d28b5411d5 | ||
|
|
1da49d29d7 | ||
|
|
7af4b913d7 | ||
|
|
a667723d93 | ||
|
|
94bfa26041 | ||
|
|
d545ca3584 | ||
|
|
773701d0c1 | ||
|
|
a3f7d0c275 | ||
|
|
5b7ded08cc | ||
|
|
60dd00ad7e | ||
|
|
ec653cae15 | ||
|
|
18bc45ea0a | ||
|
|
ebb33854d7 | ||
|
|
9efa2bbaa2 | ||
|
|
c515d037cf | ||
|
|
ee3a288fa0 | ||
|
|
c0171aa656 | ||
|
|
41d3e61261 | ||
|
|
8f74c3edc7 | ||
|
|
56ffec3173 | ||
|
|
9509a427c8 | ||
|
|
cfcfb486bf | ||
|
|
407ea69425 | ||
|
|
e1e91ea1a6 | ||
|
|
e7ea8a2c3b | ||
|
|
9f1791ce93 | ||
|
|
38cb7068ef | ||
|
|
cc154f0c16 | ||
|
|
866aa44277 | ||
|
|
ff3f88c53b | ||
|
|
3fd76b1356 | ||
|
|
a86df6c46b | ||
|
|
bdd284b9a6 | ||
|
|
fff7d4459f | ||
|
|
b85549016d | ||
|
|
6c35608404 | ||
|
|
74e3465a84 | ||
|
|
be32d933bb | ||
|
|
db89295d9b | ||
|
|
8d90fe3a8b | ||
|
|
4880392197 | ||
|
|
e10a99cc48 | ||
|
|
55b897883b | ||
|
|
fe26a74451 | ||
|
|
4cdbab7d19 | ||
|
|
3e695def23 | ||
|
|
15e4c18d54 | ||
|
|
87169480a1 | ||
|
|
bd9467b09e | ||
|
|
6216e7fdb7 | ||
|
|
6d2e897c9f | ||
|
|
ad5148daad | ||
|
|
c1e12d5898 | ||
|
|
7416c8297a | ||
|
|
9727bec7ab | ||
|
|
6ba767a848 | ||
|
|
4ad103acb6 |
103
.claude/agents/tv-validator.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: tv-validator
|
||||
description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements.
|
||||
tools: Read, Glob, Grep
|
||||
model: haiku
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations.
|
||||
|
||||
## Critical Rules to Check
|
||||
|
||||
### 1. No .tv.tsx File Suffix
|
||||
The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it.
|
||||
|
||||
**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution
|
||||
**Correct**: Use `Platform.isTV` conditional rendering in the main file:
|
||||
```typescript
|
||||
if (Platform.isTV) {
|
||||
return <TVMyComponent />;
|
||||
}
|
||||
return <MyComponent />;
|
||||
```
|
||||
|
||||
### 2. No FlashList on TV
|
||||
FlashList has focus issues on TV. Use FlatList instead.
|
||||
|
||||
**Violation**: `<FlashList` in TV code paths
|
||||
**Correct**:
|
||||
```typescript
|
||||
{Platform.isTV ? (
|
||||
<FlatList removeClippedSubviews={false} ... />
|
||||
) : (
|
||||
<FlashList ... />
|
||||
)}
|
||||
```
|
||||
|
||||
### 3. Modal Pattern
|
||||
Never use overlay/absolute-positioned modals on TV. They break back button handling.
|
||||
|
||||
**Violation**: `position: "absolute"` or `Modal` component for TV overlays
|
||||
**Correct**: Use navigation-based pattern:
|
||||
- Create Jotai atom for state
|
||||
- Hook that sets atom and calls `router.push()`
|
||||
- Page in `app/(auth)/` that reads atom
|
||||
- `Stack.Screen` with `presentation: "transparentModal"`
|
||||
|
||||
### 4. Typography
|
||||
All TV text must use `TVTypography` component.
|
||||
|
||||
**Violation**: Raw `<Text>` in TV components
|
||||
**Correct**: `<TVTypography variant="title">...</TVTypography>`
|
||||
|
||||
### 5. No Purple Accent Colors
|
||||
TV uses white for focus states, not purple.
|
||||
|
||||
**Violation**: Purple/violet colors in TV focused states
|
||||
**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds
|
||||
|
||||
### 6. Focus Handling
|
||||
- Only ONE element should have `hasTVPreferredFocus={true}`
|
||||
- Focusable items need `disabled={isModalOpen}` when overlays are visible
|
||||
- Use `onFocus`/`onBlur` with scale animations
|
||||
- Add padding for scale animations (focus scale clips without it)
|
||||
|
||||
### 7. List Configuration
|
||||
TV lists need:
|
||||
- `removeClippedSubviews={false}`
|
||||
- `overflow: "visible"` on containers
|
||||
- Sufficient padding for focus scale animations
|
||||
|
||||
### 8. Horizontal Padding
|
||||
Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20).
|
||||
|
||||
### 9. Focus Guide Navigation
|
||||
For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop.
|
||||
Use `useState` for refs (not `useRef`) to trigger re-renders.
|
||||
|
||||
## Review Process
|
||||
|
||||
1. Read the file(s) to review
|
||||
2. Check each rule above
|
||||
3. Report violations with:
|
||||
- Line number
|
||||
- What's wrong
|
||||
- How to fix it
|
||||
4. If no violations, confirm the code follows TV patterns
|
||||
|
||||
## Output Format
|
||||
|
||||
```
|
||||
## TV Validation Results
|
||||
|
||||
### ✓ Passes
|
||||
- [List of rules that pass]
|
||||
|
||||
### ✗ Violations
|
||||
- **[Rule Name]** (line X): [Description]
|
||||
Fix: [How to correct it]
|
||||
|
||||
### Recommendations
|
||||
- [Optional suggestions for improvement]
|
||||
```
|
||||
@@ -12,26 +12,59 @@ Analyze the current conversation to extract useful facts that should be remember
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Read the existing facts file at `.claude/learned-facts.md`
|
||||
1. Read the Learned Facts Index section in `CLAUDE.md` and scan existing files in `.claude/learned-facts/` to understand what's already recorded
|
||||
2. Review this conversation for learnings worth preserving
|
||||
3. For each new fact:
|
||||
- Write it concisely (1-2 sentences max)
|
||||
- Include context for why it matters
|
||||
- Add today's date
|
||||
- Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below
|
||||
- Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md`
|
||||
4. Skip facts that duplicate existing entries
|
||||
5. Append new facts to `.claude/learned-facts.md`
|
||||
5. If a new category is needed, add it to the index in `CLAUDE.md`
|
||||
|
||||
## Fact Format
|
||||
## Fact File Template
|
||||
|
||||
Use this format for each fact:
|
||||
```
|
||||
- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_
|
||||
Create each file at `.claude/learned-facts/[kebab-case-name].md`:
|
||||
|
||||
```markdown
|
||||
# [Title]
|
||||
|
||||
**Date**: YYYY-MM-DD
|
||||
**Category**: navigation | tv | native-modules | state-management | ui
|
||||
**Key files**: `relevant/paths.ts`
|
||||
|
||||
## Detail
|
||||
|
||||
[Full description of the fact, including context for why it matters]
|
||||
```
|
||||
|
||||
## Example Facts
|
||||
## Index Entry Format
|
||||
|
||||
- **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_
|
||||
- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_
|
||||
- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_
|
||||
Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`:
|
||||
|
||||
After updating the file, summarize what facts you added (or note if nothing new was learned this session).
|
||||
```
|
||||
- `kebab-case-name` | Brief one-line summary of the fact
|
||||
```
|
||||
|
||||
Categories: Navigation, UI/Headers, State/Data, Native Modules, TV Platform
|
||||
|
||||
## Example
|
||||
|
||||
File `.claude/learned-facts/state-management-pattern.md`:
|
||||
```markdown
|
||||
# State Management Pattern
|
||||
|
||||
**Date**: 2025-01-09
|
||||
**Category**: state-management
|
||||
**Key files**: `utils/atoms/`
|
||||
|
||||
## Detail
|
||||
|
||||
Use Jotai atoms for global state, NOT React Context. Atoms are defined in `utils/atoms/`.
|
||||
```
|
||||
|
||||
Index entry in `CLAUDE.md`:
|
||||
```
|
||||
State/Data:
|
||||
- `state-management-pattern` | Use Jotai atoms for global state, not React Context
|
||||
```
|
||||
|
||||
After updating, summarize what facts you added (or note if nothing new was learned this session).
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Learned Facts
|
||||
# Learned Facts (DEPRECATED)
|
||||
|
||||
This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions.
|
||||
> **DEPRECATED**: This file has been replaced by individual fact files in `.claude/learned-facts/`.
|
||||
> The compressed index is now inline in `CLAUDE.md` under "Learned Facts Index".
|
||||
> New facts should be added as individual files using the `/reflect` command.
|
||||
> This file is kept for reference only and is no longer auto-imported.
|
||||
|
||||
This file is auto-imported into CLAUDE.md and loaded at the start of each session.
|
||||
This file previously contained facts about the codebase learned from past sessions.
|
||||
|
||||
## Facts
|
||||
|
||||
@@ -24,4 +27,22 @@ 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)_
|
||||
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
|
||||
|
||||
- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_
|
||||
|
||||
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
|
||||
|
||||
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
|
||||
|
||||
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
|
||||
|
||||
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
|
||||
|
||||
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
|
||||
|
||||
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
|
||||
|
||||
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
||||
|
||||
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
|
||||
9
.claude/learned-facts/header-button-locations.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Header Button Locations
|
||||
|
||||
**Date**: 2026-01-10
|
||||
**Category**: ui
|
||||
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`, `components/common/HeaderBackButton.tsx`, `components/Chromecast.tsx`, `components/RoundButton.tsx`, `components/home/Home.tsx`, `app/(auth)/(tabs)/(home)/downloads/index.tsx`
|
||||
|
||||
## Detail
|
||||
|
||||
Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`.
|
||||
9
.claude/learned-facts/intro-modal-trigger-location.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Intro Modal Trigger Location
|
||||
|
||||
**Date**: 2025-01-09
|
||||
**Category**: navigation
|
||||
**Key files**: `components/home/Home.tsx`, `app/(auth)/(tabs)/_layout.tsx`
|
||||
|
||||
## Detail
|
||||
|
||||
The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation.
|
||||
9
.claude/learned-facts/introsheet-rendering-location.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# IntroSheet Rendering Location
|
||||
|
||||
**Date**: 2025-01-09
|
||||
**Category**: navigation
|
||||
**Key files**: `providers/IntroSheetProvider`, `components/IntroSheet`
|
||||
|
||||
## Detail
|
||||
|
||||
The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs.
|
||||
9
.claude/learned-facts/macos-header-buttons-fix.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# macOS Header Buttons Fix
|
||||
|
||||
**Date**: 2026-01-10
|
||||
**Category**: ui
|
||||
**Key files**: `components/common/HeaderBackButton.tsx`, `app/(auth)/(tabs)/(home)/_layout.tsx`
|
||||
|
||||
## Detail
|
||||
|
||||
Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app.
|
||||
9
.claude/learned-facts/mark-as-played-flow.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Mark as Played Flow
|
||||
|
||||
**Date**: 2026-01-10
|
||||
**Category**: state-management
|
||||
**Key files**: `components/PlayedStatus.tsx`, `hooks/useMarkAsPlayed.ts`
|
||||
|
||||
## Detail
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# MPV avfoundation-composite-osd Ordering
|
||||
|
||||
**Date**: 2026-01-22
|
||||
**Category**: native-modules
|
||||
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
|
||||
|
||||
## Detail
|
||||
|
||||
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).
|
||||
9
.claude/learned-facts/mpv-tvos-player-exit-freeze.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# MPV tvOS Player Exit Freeze
|
||||
|
||||
**Date**: 2026-01-22
|
||||
**Category**: native-modules
|
||||
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
|
||||
|
||||
## Detail
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Native Bottom Tabs + useRouter Conflict
|
||||
|
||||
**Date**: 2025-01-09
|
||||
**Category**: navigation
|
||||
**Key files**: `providers/`, `app/_layout.tsx`
|
||||
|
||||
## Detail
|
||||
|
||||
When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead.
|
||||
9
.claude/learned-facts/native-swiftui-view-sizing.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Native SwiftUI View Sizing
|
||||
|
||||
**Date**: 2026-01-25
|
||||
**Category**: native-modules
|
||||
**Key files**: `modules/`
|
||||
|
||||
## Detail
|
||||
|
||||
When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Platform-Specific File Suffix (.tv.tsx) Does NOT Work
|
||||
|
||||
**Date**: 2026-01-26
|
||||
**Category**: tv
|
||||
**Key files**: `app/`, `components/`
|
||||
|
||||
## Detail
|
||||
|
||||
The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Stack Screen Header Configuration
|
||||
|
||||
**Date**: 2026-01-10
|
||||
**Category**: ui
|
||||
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`
|
||||
|
||||
## Detail
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Streamystats Components Location
|
||||
|
||||
**Date**: 2026-01-25
|
||||
**Category**: tv
|
||||
**Key files**: `components/home/StreamystatsRecommendations.tv.tsx`, `components/home/StreamystatsPromotedWatchlists.tv.tsx`, `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`
|
||||
|
||||
## Detail
|
||||
|
||||
Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`.
|
||||
9
.claude/learned-facts/tab-folder-naming.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Tab Folder Naming
|
||||
|
||||
**Date**: 2025-01-09
|
||||
**Category**: navigation
|
||||
**Key files**: `app/(auth)/(tabs)/`
|
||||
|
||||
## Detail
|
||||
|
||||
The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Thread-Safe State for Stop Flags
|
||||
|
||||
**Date**: 2026-01-22
|
||||
**Category**: native-modules
|
||||
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
|
||||
|
||||
## Detail
|
||||
|
||||
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.
|
||||
9
.claude/learned-facts/tv-grid-layout-pattern.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# TV Grid Layout Pattern
|
||||
|
||||
**Date**: 2026-01-25
|
||||
**Category**: tv
|
||||
**Key files**: `components/tv/`
|
||||
|
||||
## Detail
|
||||
|
||||
For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing.
|
||||
9
.claude/learned-facts/tv-horizontal-padding-standard.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# TV Horizontal Padding Standard
|
||||
|
||||
**Date**: 2026-01-25
|
||||
**Category**: tv
|
||||
**Key files**: `components/tv/`, `app/(auth)/(tabs)/`
|
||||
|
||||
## Detail
|
||||
|
||||
TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small.
|
||||
@@ -0,0 +1,9 @@
|
||||
# TV Modals Must Use Navigation Pattern
|
||||
|
||||
**Date**: 2026-01-24
|
||||
**Category**: tv
|
||||
**Key files**: `hooks/useTVOptionModal.ts`, `app/(auth)/tv-option-modal.tsx`
|
||||
|
||||
## Detail
|
||||
|
||||
On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page.
|
||||
@@ -0,0 +1,9 @@
|
||||
# useNetworkAwareQueryClient Limitations
|
||||
|
||||
**Date**: 2026-01-10
|
||||
**Category**: state-management
|
||||
**Key files**: `hooks/useNetworkAwareQueryClient.ts`
|
||||
|
||||
## Detail
|
||||
|
||||
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.
|
||||
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||
|
||||
## Main Technologies
|
||||
@@ -40,30 +40,9 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `plugins/` – Expo/Metro plugins
|
||||
|
||||
## Code Quality Standards
|
||||
## Coding Standards
|
||||
|
||||
**CRITICAL: Code must be production-ready, reliable, and maintainable**
|
||||
|
||||
### Type Safety
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
|
||||
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
|
||||
- When facing type issues, create proper type definitions and helper functions instead of using `any`
|
||||
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
|
||||
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
|
||||
- Enable and respect strict TypeScript compiler options
|
||||
- Define explicit return types for functions
|
||||
- Use discriminated unions for complex state
|
||||
|
||||
### Code Reliability
|
||||
- Implement comprehensive error handling with try-catch blocks
|
||||
- Validate all external inputs (API responses, user input, query params)
|
||||
- Handle edge cases explicitly (empty arrays, null, undefined)
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
|
||||
- Add runtime checks for critical operations
|
||||
- Implement proper loading and error states in components
|
||||
|
||||
### Best Practices
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
@@ -71,10 +50,8 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- Follow BiomeJS formatting and linting rules
|
||||
- Use `const` over `let`, avoid `var` entirely
|
||||
- Implement proper error boundaries
|
||||
- Use React.memo() for performance optimization when needed
|
||||
- Use React.memo() for performance optimization
|
||||
- Handle both mobile and TV navigation patterns
|
||||
- Write self-documenting code with clear intent
|
||||
- Add comments only when code complexity requires explanation
|
||||
|
||||
## API Integration
|
||||
|
||||
@@ -108,18 +85,6 @@ Exemples:
|
||||
- `fix(auth): handle expired JWT tokens`
|
||||
- `chore(deps): update Jellyfin SDK`
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
|
||||
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
|
||||
- **NEVER add or remove keys** - Crowdin manages the key structure
|
||||
- **Editing translation values is safe** - Bidirectional sync handles merges
|
||||
- Prefer letting Crowdin translators update values, but direct edits work if needed
|
||||
- **Crowdin workflow**:
|
||||
- New keys added to `en.json` sync to Crowdin automatically
|
||||
- Approved translations sync back to language files via GitHub integration
|
||||
- The source of truth is `en.json` for structure, Crowdin for translations
|
||||
|
||||
## Special Instructions
|
||||
|
||||
- Prioritize cross-platform compatibility (mobile + TV)
|
||||
|
||||
7
.gitignore
vendored
@@ -50,8 +50,6 @@ npm-debug.*
|
||||
.idea/
|
||||
.ruby-lsp
|
||||
.cursor/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Environment and Configuration
|
||||
expo-env.d.ts
|
||||
@@ -63,6 +61,8 @@ expo-env.d.ts
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
/profiles/
|
||||
certs/
|
||||
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
@@ -72,4 +72,5 @@ modules/background-downloader/android/build/*
|
||||
/modules/mpv-player/android/build
|
||||
|
||||
# ios:unsigned-build Artifacts
|
||||
build/
|
||||
build/
|
||||
.claude/settings.local.json
|
||||
|
||||
160
CLAUDE.md
@@ -1,12 +1,42 @@
|
||||
# CLAUDE.md
|
||||
|
||||
@.claude/learned-facts.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Learned Facts Index
|
||||
|
||||
IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in `.claude/learned-facts/` before relying on assumptions.
|
||||
|
||||
Navigation:
|
||||
- `native-bottom-tabs-userouter-conflict` | useRouter() at provider level causes tab switches; use static router import
|
||||
- `introsheet-rendering-location` | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks
|
||||
- `intro-modal-trigger-location` | Trigger in Home.tsx, not tabs _layout.tsx
|
||||
- `tab-folder-naming` | Use underscore prefix: (_home) not (home)
|
||||
|
||||
UI/Headers:
|
||||
- `macos-header-buttons-fix` | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity
|
||||
- `header-button-locations` | Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc.
|
||||
- `stack-screen-header-configuration` | Sub-pages need explicit Stack.Screen with headerTransparent + back button
|
||||
|
||||
State/Data:
|
||||
- `use-network-aware-query-client-limitations` | Object.create breaks private fields; only for invalidateQueries
|
||||
- `mark-as-played-flow` | PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates
|
||||
|
||||
Native Modules:
|
||||
- `mpv-tvos-player-exit-freeze` | mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global()
|
||||
- `mpv-avfoundation-composite-osd-ordering` | MUST follow vo=avfoundation, before hwdec options
|
||||
- `thread-safe-state-for-stop-flags` | Stop flags need synchronous setter (stateQueue.sync not async)
|
||||
- `native-swiftui-view-sizing` | Need explicit frame + intrinsicContentSize override in ExpoView
|
||||
|
||||
TV Platform:
|
||||
- `tv-modals-must-use-navigation-pattern` | Use atom+router.push(), never overlay/absolute modals
|
||||
- `tv-grid-layout-pattern` | ScrollView+flexWrap, not FlatList numColumns
|
||||
- `tv-horizontal-padding-standard` | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20
|
||||
- `streamystats-components-location` | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx
|
||||
- `platform-specific-file-suffix-does-not-work` | .tv.tsx doesn't work; use Platform.isTV conditional rendering
|
||||
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Seerr integration.
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -65,6 +95,7 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
|
||||
**State Management**:
|
||||
- Global state uses Jotai atoms in `utils/atoms/`
|
||||
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
|
||||
- **IMPORTANT**: When adding a setting to the settings atom, ensure it's toggleable in the settings view (either TV or mobile, depending on the feature scope)
|
||||
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
|
||||
- Server state uses React Query with `@tanstack/react-query`
|
||||
|
||||
@@ -128,9 +159,132 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
- Handle both mobile and TV navigation patterns
|
||||
- Use existing atoms, hooks, and utilities before creating new ones
|
||||
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
|
||||
- **Translations**: When adding a translation key to a Text component, ensure the key exists in both `translations/en.json` and `translations/sv.json`. Before adding new keys, check if an existing key already covers the use case.
|
||||
|
||||
## Platform Considerations
|
||||
|
||||
- TV version uses `:tv` suffix for scripts
|
||||
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
||||
- Some features disabled on TV (e.g., notifications, Chromecast)
|
||||
- **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 React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.**
|
||||
|
||||
### TV Component Rendering Pattern
|
||||
|
||||
**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead.
|
||||
|
||||
**Pattern for TV-specific pages and 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
|
||||
- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly
|
||||
|
||||
### TV Option Selectors and Focus Management
|
||||
|
||||
For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md).
|
||||
|
||||
### 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.
|
||||
|
||||
101
README.md
@@ -22,75 +22,58 @@
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
||||
|
||||
<img src="./assets/images/seerr.PNG" width="21%">
|
||||
<img src="./assets/images/jellyseerr.PNG" width="21%">
|
||||
</p>
|
||||
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
### 🎬 Media Playback
|
||||
- 🚀 **Skip Intro / Credits**: Automatically skip intros and credits during playback
|
||||
- 🖼️ **Trickplay Images**: Chapter previews with thumbnails when seeking
|
||||
- 🎵 **Music Library**: Full support for music playback with playlists and queue management
|
||||
- 📺 **Live TV**: Watch live television streams
|
||||
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
|
||||
- 📥 **Download media**: Save your media locally and watch it offline
|
||||
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
|
||||
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
|
||||
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
|
||||
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
||||
- 🎥 **MPV Player**: Powerful open-source player with wide format support
|
||||
|
||||
### 📱 Media Management
|
||||
- 📥 **Download Media**: Save movies, shows, and music locally for offline viewing
|
||||
- ⭐ **Favorites**: Quick access to your favorite content
|
||||
- 📋 **Watchlists**: Create and manage custom watchlists with Streamystats integration
|
||||
- 🔖 **Continue Watching**: Pick up right where you left off
|
||||
- 🎯 **Next Up**: Suggestions for your next episode
|
||||
## 🧪 Experimental Features
|
||||
|
||||
### ⚙️ Advanced Features
|
||||
- 🤖 **Seerr Integration**: Request new media directly in the app
|
||||
- 🔍 **Smart Search**: Powerful search with Marlin Search and Streamystats support
|
||||
- 👁️ **Active Sessions**: View all active streams on your server
|
||||
- 🌐 **Multi-Language**: Available in 20+ languages with Crowdin integration
|
||||
- 🎨 **Customizable**: Personalize your home screen and settings
|
||||
- 🔌 **Plugin System**: Centralized settings sync across all devices via Jellyfin plugin
|
||||
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
|
||||
|
||||
## 🧩 How It Works
|
||||
|
||||
### 📥 Downloads
|
||||
### 📥 Downloading
|
||||
|
||||
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
|
||||
|
||||
### 🧩 Streamyfin Plugin
|
||||
|
||||
The Jellyfin Plugin for Streamyfin synchronizes settings across all your devices and users. Install it on your Jellyfin server to enable:
|
||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||
|
||||
- Automatic Seerr login with no user input required
|
||||
- Default language preferences for audio and subtitles
|
||||
- Configure download settings and search providers (Marlin, Streamystats)
|
||||
- Customize your home screen layout and sections
|
||||
- Centralized configuration management
|
||||
- Set your preferred default languages
|
||||
- Configure download method and search provider
|
||||
- Personalize your home screen
|
||||
- And much more
|
||||
|
||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||
|
||||
### 📡 Chromecast
|
||||
|
||||
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
||||
|
||||
### 🎬 MPV Player
|
||||
|
||||
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
|
||||
|
||||
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
|
||||
|
||||
### 🎵 Music Library
|
||||
### 🔍 Jellysearch
|
||||
|
||||
Full music library support with playlists, queue management, background playback, and offline downloads.
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
||||
|
||||
### 🔍 Search Providers
|
||||
|
||||
Streamyfin supports multiple search providers:
|
||||
|
||||
- **Marlin Search**: Fast semantic search for your Jellyfin library
|
||||
- **Streamystats**: Advanced statistics and personalized recommendations
|
||||
- **Jellysearch**: Fast full-text search proxy ([Jellysearch](https://gitlab.com/DomiStyle/jellysearch))
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
## 🛣️ Roadmap
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||
|
||||
## 📥 Download Streamyfin
|
||||
|
||||
@@ -130,12 +113,13 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
||||
|
||||
### 👨💻 Development Info
|
||||
|
||||
1. Use Node.js `>20`
|
||||
2. Install dependencies: `bun i && bun run submodule-reload`
|
||||
3. Make sure you have Xcode and/or Android Studio installed ([Expo setup guide](https://docs.expo.dev/workflow/android-studio-emulator/))
|
||||
4. Install the [BiomeJS extension](https://biomejs.dev/) in your IDE
|
||||
5. Run `npm run prebuild`
|
||||
6. Create an Expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||
4. run `npm run prebuild`
|
||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||
|
||||
For the TV version suffix the npm commands with `:tv`.
|
||||
|
||||
@@ -153,20 +137,10 @@ Need assistance or have any questions?
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
1. **Q: Why can't I see my libraries in Streamyfin?**
|
||||
A: Ensure your Jellyfin server is running a recent version (10.10.0+) and that you have proper permissions to access the libraries.
|
||||
|
||||
2. **Q: How do I enable downloads?**
|
||||
A: Downloads use FFmpeg to convert HLS streams. Ensure your server has transcoding enabled and sufficient resources.
|
||||
|
||||
3. **Q: Does Streamyfin support subtitles?**
|
||||
A: Yes, with full customization including size, color, position, and automatic language selection.
|
||||
|
||||
4. **Q: Can I use Streamyfin on Apple TV or Android TV?**
|
||||
A: Yes, Streamyfin has dedicated TV builds, but they are currently in **early development** and may have stability issues.
|
||||
|
||||
5. **Q: How do I set up Seerr integration?**
|
||||
A: Go to Settings → Plugins → Seerr, enter your server URL and Jellyfin credentials.
|
||||
1. Q: Why can't I see my libraries in Streamyfin?
|
||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
|
||||
2. Q: Why can't I see my music library?
|
||||
A: We don't currently support music and are unlikely to support music in the near future
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
@@ -280,9 +254,7 @@ A special mention to the following people and projects for their contributions:
|
||||
## 📄 License
|
||||
|
||||
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
||||
|
||||
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
||||
|
||||
Key points of the MPL-2.0:
|
||||
|
||||
- You can use the software for any purpose
|
||||
@@ -291,13 +263,10 @@ Key points of the MPL-2.0:
|
||||
- You must disclose your source code for any modifications to the covered files
|
||||
- Larger works may combine MPL code with code under other licenses
|
||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||
|
||||
For the full text of the license, please see the LICENSE file in this repository.
|
||||
- For the full text of the license, please see the LICENSE file in this repository
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
|
||||
|
||||
## 🤝 Sponsorship
|
||||
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster).
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||
|
||||
24
app.json
@@ -23,7 +23,8 @@
|
||||
},
|
||||
"UISupportsTrueScreenSizeOnMac": true,
|
||||
"UIFileSharingEnabled": true,
|
||||
"LSSupportsOpeningDocumentsInPlace": true
|
||||
"LSSupportsOpeningDocumentsInPlace": true,
|
||||
"AVInitialRouteSharingPolicy": "LongFormAudio"
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
@@ -55,10 +56,27 @@
|
||||
"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",
|
||||
"./plugins/withTVUserManagement.js",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -121,6 +139,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
|
||||
|
||||
@@ -43,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"),
|
||||
@@ -62,7 +62,7 @@ export default function IndexLayout() {
|
||||
name='sessions/index'
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
headerShown: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -81,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,
|
||||
@@ -99,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,
|
||||
@@ -117,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,
|
||||
@@ -135,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,
|
||||
@@ -153,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,
|
||||
@@ -171,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,
|
||||
@@ -189,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,
|
||||
@@ -207,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,
|
||||
@@ -222,9 +230,10 @@ export default function IndexLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/seerr/page'
|
||||
name='settings/plugins/jellyseerr/page'
|
||||
options={{
|
||||
title: "Seerr",
|
||||
title: "Jellyseerr",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -243,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,
|
||||
@@ -261,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,
|
||||
@@ -279,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,
|
||||
@@ -297,6 +309,7 @@ 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,
|
||||
@@ -315,6 +328,7 @@ export default function IndexLayout() {
|
||||
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,15 +1,6 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Home } from "../../../../components/home/Home";
|
||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
||||
|
||||
const Index = () => {
|
||||
const { settings } = useSettings();
|
||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
||||
|
||||
if (showLargeHomeCarousel) {
|
||||
return <HomeWithCarousel />;
|
||||
}
|
||||
|
||||
return <Home />;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ 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);
|
||||
@@ -104,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 />;
|
||||
}
|
||||
|
||||
809
app/(auth)/(tabs)/(home)/settings.tv.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||
import type { TVOptionItem } from "@/components/tv";
|
||||
import {
|
||||
TVLogoutButton,
|
||||
TVSectionHeader,
|
||||
TVSettingsOptionButton,
|
||||
TVSettingsRow,
|
||||
TVSettingsStepper,
|
||||
TVSettingsTextInput,
|
||||
TVSettingsToggle,
|
||||
} from "@/components/tv";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||
import { APP_LANGUAGES } from "@/i18n";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
AudioTranscodeMode,
|
||||
InactivityTimeout,
|
||||
type MpvCacheMode,
|
||||
TVTypographyScale,
|
||||
useSettings,
|
||||
} from "@/utils/atoms/settings";
|
||||
import {
|
||||
getPreviousServers,
|
||||
type SavedServer,
|
||||
type SavedServerAccount,
|
||||
} from "@/utils/secureCredentials";
|
||||
|
||||
export default function SettingsTV() {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { showOptions } = useTVOptionModal();
|
||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
// Local state for OpenSubtitles API key (only commit on blur)
|
||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||
settings.openSubtitlesApiKey || "",
|
||||
);
|
||||
|
||||
// PIN/Password modal state for user switching
|
||||
const [pinModalVisible, setPinModalVisible] = useState(false);
|
||||
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
||||
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedAccount, setSelectedAccount] =
|
||||
useState<SavedServerAccount | null>(null);
|
||||
|
||||
// Track if any modal is open to disable background focus
|
||||
const isAnyModalOpen = pinModalVisible || passwordModalVisible;
|
||||
|
||||
// Get current server and other accounts
|
||||
const currentServer = useMemo(() => {
|
||||
if (!api?.basePath) return null;
|
||||
const servers = getPreviousServers();
|
||||
return servers.find((s) => s.address === api.basePath) || null;
|
||||
}, [api?.basePath]);
|
||||
|
||||
const otherAccounts = useMemo(() => {
|
||||
if (!currentServer || !user?.Id) return [];
|
||||
return currentServer.accounts.filter(
|
||||
(account) => account.userId !== user.Id,
|
||||
);
|
||||
}, [currentServer, user?.Id]);
|
||||
|
||||
const hasOtherAccounts = otherAccounts.length > 0;
|
||||
|
||||
// Handle account selection from modal
|
||||
const handleAccountSelect = async (account: SavedServerAccount) => {
|
||||
if (!currentServer) return;
|
||||
|
||||
if (account.securityType === "none") {
|
||||
// Direct login with saved credential
|
||||
try {
|
||||
await loginWithSavedCredential(currentServer.address, account.userId);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t("server.session_expired");
|
||||
const isSessionExpired = errorMessage.includes(
|
||||
t("server.session_expired"),
|
||||
);
|
||||
Alert.alert(
|
||||
isSessionExpired
|
||||
? t("server.session_expired")
|
||||
: t("login.connection_failed"),
|
||||
isSessionExpired ? t("server.please_login_again") : errorMessage,
|
||||
);
|
||||
}
|
||||
} else if (account.securityType === "pin") {
|
||||
// Show PIN modal
|
||||
setSelectedServer(currentServer);
|
||||
setSelectedAccount(account);
|
||||
setPinModalVisible(true);
|
||||
} else if (account.securityType === "password") {
|
||||
// Show password modal
|
||||
setSelectedServer(currentServer);
|
||||
setSelectedAccount(account);
|
||||
setPasswordModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle successful PIN entry
|
||||
const handlePinSuccess = async () => {
|
||||
setPinModalVisible(false);
|
||||
if (selectedServer && selectedAccount) {
|
||||
try {
|
||||
await loginWithSavedCredential(
|
||||
selectedServer.address,
|
||||
selectedAccount.userId,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t("server.session_expired");
|
||||
const isSessionExpired = errorMessage.includes(
|
||||
t("server.session_expired"),
|
||||
);
|
||||
Alert.alert(
|
||||
isSessionExpired
|
||||
? t("server.session_expired")
|
||||
: t("login.connection_failed"),
|
||||
isSessionExpired ? t("server.please_login_again") : errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
setSelectedServer(null);
|
||||
setSelectedAccount(null);
|
||||
};
|
||||
|
||||
// Handle password submission
|
||||
const handlePasswordSubmit = async (password: string) => {
|
||||
if (selectedServer && selectedAccount) {
|
||||
await loginWithPassword(
|
||||
selectedServer.address,
|
||||
selectedAccount.username,
|
||||
password,
|
||||
);
|
||||
}
|
||||
setPasswordModalVisible(false);
|
||||
setSelectedServer(null);
|
||||
setSelectedAccount(null);
|
||||
};
|
||||
|
||||
// Handle switch user button press
|
||||
const handleSwitchUser = () => {
|
||||
if (!currentServer || !user?.Id) return;
|
||||
showUserSwitchModal(currentServer, user.Id, {
|
||||
onAccountSelect: handleAccountSelect,
|
||||
});
|
||||
};
|
||||
|
||||
const currentAudioTranscode =
|
||||
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
|
||||
const currentSubtitleMode =
|
||||
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
||||
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
||||
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||
const currentTypographyScale =
|
||||
settings.tvTypographyScale || TVTypographyScale.Default;
|
||||
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
|
||||
const currentLanguage = settings.preferedLanguage;
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
// Cache mode options
|
||||
const cacheModeOptions: TVOptionItem<MpvCacheMode>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.buffer.cache_auto"),
|
||||
value: "auto",
|
||||
selected: currentCacheMode === "auto",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.buffer.cache_yes"),
|
||||
value: "yes",
|
||||
selected: currentCacheMode === "yes",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.buffer.cache_no"),
|
||||
value: "no",
|
||||
selected: currentCacheMode === "no",
|
||||
},
|
||||
],
|
||||
[t, currentCacheMode],
|
||||
);
|
||||
|
||||
// Typography scale options
|
||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.appearance.display_size_small"),
|
||||
value: TVTypographyScale.Small,
|
||||
selected: currentTypographyScale === TVTypographyScale.Small,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.appearance.display_size_default"),
|
||||
value: TVTypographyScale.Default,
|
||||
selected: currentTypographyScale === TVTypographyScale.Default,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.appearance.display_size_large"),
|
||||
value: TVTypographyScale.Large,
|
||||
selected: currentTypographyScale === TVTypographyScale.Large,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.appearance.display_size_extra_large"),
|
||||
value: TVTypographyScale.ExtraLarge,
|
||||
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
|
||||
},
|
||||
],
|
||||
[t, currentTypographyScale],
|
||||
);
|
||||
|
||||
// Language options
|
||||
const languageOptions: TVOptionItem<string | undefined>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.languages.system"),
|
||||
value: undefined,
|
||||
selected: !currentLanguage,
|
||||
},
|
||||
...APP_LANGUAGES.map((lang) => ({
|
||||
label: lang.label,
|
||||
value: lang.value,
|
||||
selected: currentLanguage === lang.value,
|
||||
})),
|
||||
],
|
||||
[t, currentLanguage],
|
||||
);
|
||||
|
||||
// Inactivity timeout options (TV security feature)
|
||||
const currentInactivityTimeout =
|
||||
settings.inactivityTimeout ?? InactivityTimeout.Disabled;
|
||||
|
||||
const inactivityTimeoutOptions: TVOptionItem<InactivityTimeout>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.security.inactivity_timeout.disabled"),
|
||||
value: InactivityTimeout.Disabled,
|
||||
selected: currentInactivityTimeout === InactivityTimeout.Disabled,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.security.inactivity_timeout.1_minute"),
|
||||
value: InactivityTimeout.OneMinute,
|
||||
selected: currentInactivityTimeout === InactivityTimeout.OneMinute,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.security.inactivity_timeout.5_minutes"),
|
||||
value: InactivityTimeout.FiveMinutes,
|
||||
selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.security.inactivity_timeout.15_minutes"),
|
||||
value: InactivityTimeout.FifteenMinutes,
|
||||
selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.security.inactivity_timeout.30_minutes"),
|
||||
value: InactivityTimeout.ThirtyMinutes,
|
||||
selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.security.inactivity_timeout.1_hour"),
|
||||
value: InactivityTimeout.OneHour,
|
||||
selected: currentInactivityTimeout === InactivityTimeout.OneHour,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.security.inactivity_timeout.4_hours"),
|
||||
value: InactivityTimeout.FourHours,
|
||||
selected: currentInactivityTimeout === InactivityTimeout.FourHours,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.security.inactivity_timeout.24_hours"),
|
||||
value: InactivityTimeout.TwentyFourHours,
|
||||
selected:
|
||||
currentInactivityTimeout === InactivityTimeout.TwentyFourHours,
|
||||
},
|
||||
],
|
||||
[t, currentInactivityTimeout],
|
||||
);
|
||||
|
||||
// Get display labels for option buttons
|
||||
const audioTranscodeLabel = useMemo(() => {
|
||||
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
||||
return option?.label || t("home.settings.audio.transcode_mode.auto");
|
||||
}, [audioTranscodeModeOptions, t]);
|
||||
|
||||
const subtitleModeLabel = useMemo(() => {
|
||||
const option = subtitleModeOptions.find((o) => o.selected);
|
||||
return option?.label || t("home.settings.subtitles.modes.Default");
|
||||
}, [subtitleModeOptions, t]);
|
||||
|
||||
const alignXLabel = useMemo(() => {
|
||||
const option = alignXOptions.find((o) => o.selected);
|
||||
return option?.label || "Center";
|
||||
}, [alignXOptions]);
|
||||
|
||||
const alignYLabel = useMemo(() => {
|
||||
const option = alignYOptions.find((o) => o.selected);
|
||||
return option?.label || "Bottom";
|
||||
}, [alignYOptions]);
|
||||
|
||||
const typographyScaleLabel = useMemo(() => {
|
||||
const option = typographyScaleOptions.find((o) => o.selected);
|
||||
return option?.label || t("home.settings.appearance.display_size_default");
|
||||
}, [typographyScaleOptions, t]);
|
||||
|
||||
const cacheModeLabel = useMemo(() => {
|
||||
const option = cacheModeOptions.find((o) => o.selected);
|
||||
return option?.label || t("home.settings.buffer.cache_auto");
|
||||
}, [cacheModeOptions, t]);
|
||||
|
||||
const languageLabel = useMemo(() => {
|
||||
if (!currentLanguage) return t("home.settings.languages.system");
|
||||
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
||||
return option?.label || t("home.settings.languages.system");
|
||||
}, [currentLanguage, t]);
|
||||
|
||||
const inactivityTimeoutLabel = useMemo(() => {
|
||||
const option = inactivityTimeoutOptions.find((o) => o.selected);
|
||||
return (
|
||||
option?.label || t("home.settings.security.inactivity_timeout.disabled")
|
||||
);
|
||||
}, [inactivityTimeoutOptions, t]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 120,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingHorizontal: insets.left + 80,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.title,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("home.settings.settings_title")}
|
||||
</Text>
|
||||
|
||||
{/* Account Section */}
|
||||
<TVSectionHeader title={t("home.settings.switch_user.account")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.switch_user.switch_user")}
|
||||
value={user?.Name || "-"}
|
||||
onPress={handleSwitchUser}
|
||||
disabled={!hasOtherAccounts || isAnyModalOpen}
|
||||
isFirst
|
||||
/>
|
||||
|
||||
{/* Security Section */}
|
||||
<TVSectionHeader title={t("home.settings.security.title")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.security.inactivity_timeout.title")}
|
||||
value={inactivityTimeoutLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.security.inactivity_timeout.title"),
|
||||
options: inactivityTimeoutOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({ inactivityTimeout: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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 }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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.mpvSubtitleScale ?? 1.0}
|
||||
onDecrease={() => {
|
||||
const newValue = Math.max(
|
||||
0.1,
|
||||
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
|
||||
);
|
||||
updateSettings({
|
||||
mpvSubtitleScale: Math.round(newValue * 10) / 10,
|
||||
});
|
||||
}}
|
||||
onIncrease={() => {
|
||||
const newValue = Math.min(
|
||||
3.0,
|
||||
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
|
||||
);
|
||||
updateSettings({
|
||||
mpvSubtitleScale: Math.round(newValue * 10) / 10,
|
||||
});
|
||||
}}
|
||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||
/>
|
||||
<TVSettingsStepper
|
||||
label='Vertical Margin'
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
onDecrease={() => {
|
||||
const newValue = Math.max(
|
||||
0,
|
||||
(settings.mpvSubtitleMarginY ?? 0) - 5,
|
||||
);
|
||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||
}}
|
||||
onIncrease={() => {
|
||||
const newValue = Math.min(
|
||||
100,
|
||||
(settings.mpvSubtitleMarginY ?? 0) + 5,
|
||||
);
|
||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||
}}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label='Horizontal Alignment'
|
||||
value={alignXLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: "Horizontal Alignment",
|
||||
options: alignXOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label='Vertical Alignment'
|
||||
value={alignYLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: "Vertical Alignment",
|
||||
options: alignYOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
mpvSubtitleAlignY: value as "top" | "center" | "bottom",
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* OpenSubtitles Section */}
|
||||
<TVSectionHeader
|
||||
title={
|
||||
t("home.settings.subtitles.opensubtitles_title") ||
|
||||
"OpenSubtitles"
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: "#9CA3AF",
|
||||
fontSize: typography.callout - 2,
|
||||
marginBottom: 16,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{t("home.settings.subtitles.opensubtitles_hint") ||
|
||||
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
|
||||
</Text>
|
||||
<TVSettingsTextInput
|
||||
label={
|
||||
t("home.settings.subtitles.opensubtitles_api_key") || "API Key"
|
||||
}
|
||||
value={openSubtitlesApiKey}
|
||||
placeholder={
|
||||
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
|
||||
"Enter API key..."
|
||||
}
|
||||
onChangeText={setOpenSubtitlesApiKey}
|
||||
onBlur={() => updateSettings({ openSubtitlesApiKey })}
|
||||
secureTextEntry
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: "#6B7280",
|
||||
fontSize: typography.callout - 4,
|
||||
marginTop: 8,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{t("home.settings.subtitles.opensubtitles_get_key") ||
|
||||
"Get your free API key at opensubtitles.com/en/consumers"}
|
||||
</Text>
|
||||
|
||||
{/* Buffer Settings Section */}
|
||||
<TVSectionHeader title={t("home.settings.buffer.title")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.buffer.cache_mode")}
|
||||
value={cacheModeLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.buffer.cache_mode"),
|
||||
options: cacheModeOptions,
|
||||
onSelect: (value) => updateSettings({ mpvCacheEnabled: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TVSettingsStepper
|
||||
label={t("home.settings.buffer.buffer_duration")}
|
||||
value={settings.mpvCacheSeconds ?? 10}
|
||||
onDecrease={() => {
|
||||
const newValue = Math.max(
|
||||
5,
|
||||
(settings.mpvCacheSeconds ?? 10) - 5,
|
||||
);
|
||||
updateSettings({ mpvCacheSeconds: newValue });
|
||||
}}
|
||||
onIncrease={() => {
|
||||
const newValue = Math.min(
|
||||
120,
|
||||
(settings.mpvCacheSeconds ?? 10) + 5,
|
||||
);
|
||||
updateSettings({ mpvCacheSeconds: newValue });
|
||||
}}
|
||||
formatValue={(v) => `${v}s`}
|
||||
/>
|
||||
<TVSettingsStepper
|
||||
label={t("home.settings.buffer.max_cache_size")}
|
||||
value={settings.mpvDemuxerMaxBytes ?? 150}
|
||||
onDecrease={() => {
|
||||
const newValue = Math.max(
|
||||
50,
|
||||
(settings.mpvDemuxerMaxBytes ?? 150) - 25,
|
||||
);
|
||||
updateSettings({ mpvDemuxerMaxBytes: newValue });
|
||||
}}
|
||||
onIncrease={() => {
|
||||
const newValue = Math.min(
|
||||
500,
|
||||
(settings.mpvDemuxerMaxBytes ?? 150) + 25,
|
||||
);
|
||||
updateSettings({ mpvDemuxerMaxBytes: newValue });
|
||||
}}
|
||||
formatValue={(v) => `${v} MB`}
|
||||
/>
|
||||
<TVSettingsStepper
|
||||
label={t("home.settings.buffer.max_backward_cache")}
|
||||
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
||||
onDecrease={() => {
|
||||
const newValue = Math.max(
|
||||
25,
|
||||
(settings.mpvDemuxerMaxBackBytes ?? 50) - 25,
|
||||
);
|
||||
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
|
||||
}}
|
||||
onIncrease={() => {
|
||||
const newValue = Math.min(
|
||||
200,
|
||||
(settings.mpvDemuxerMaxBackBytes ?? 50) + 25,
|
||||
);
|
||||
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
|
||||
}}
|
||||
formatValue={(v) => `${v} MB`}
|
||||
/>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.appearance.display_size")}
|
||||
value={typographyScaleLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.appearance.display_size"),
|
||||
options: typographyScaleOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({ tvTypographyScale: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.languages.app_language")}
|
||||
value={languageLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.languages.app_language"),
|
||||
options: languageOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({ preferedLanguage: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t(
|
||||
"home.settings.appearance.merge_next_up_continue_watching",
|
||||
)}
|
||||
value={settings.mergeNextUpAndContinueWatching}
|
||||
onToggle={(value) =>
|
||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||
}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.show_home_backdrop")}
|
||||
value={settings.showHomeBackdrop}
|
||||
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.show_hero_carousel")}
|
||||
value={settings.showTVHeroCarousel}
|
||||
onToggle={(value) => updateSettings({ showTVHeroCarousel: value })}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.show_series_poster_on_episode")}
|
||||
value={settings.showSeriesPosterOnEpisode}
|
||||
onToggle={(value) =>
|
||||
updateSettings({ showSeriesPosterOnEpisode: value })
|
||||
}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.theme_music")}
|
||||
value={settings.tvThemeMusicEnabled}
|
||||
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: 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>
|
||||
|
||||
{/* PIN Entry Modal */}
|
||||
<TVPINEntryModal
|
||||
visible={pinModalVisible}
|
||||
onClose={() => {
|
||||
setPinModalVisible(false);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}}
|
||||
onSuccess={handlePinSuccess}
|
||||
onForgotPIN={() => {
|
||||
setPinModalVisible(false);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}}
|
||||
serverUrl={selectedServer?.address || ""}
|
||||
userId={selectedAccount?.userId || ""}
|
||||
username={selectedAccount?.username || ""}
|
||||
/>
|
||||
|
||||
{/* Password Entry Modal */}
|
||||
<TVPasswordEntryModal
|
||||
visible={passwordModalVisible}
|
||||
onClose={() => {
|
||||
setPasswordModalVisible(false);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}}
|
||||
onSubmit={handlePasswordSubmit}
|
||||
username={selectedAccount?.username || ""}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export default function page() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function page() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { GestureControls } from "@/components/settings/GestureControls";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { MpvBufferSettings } from "@/components/settings/MpvBufferSettings";
|
||||
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
||||
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function PlaybackControlsPage() {
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
<PlaybackControlsSettings />
|
||||
<MpvBufferSettings />
|
||||
</MediaProvider>
|
||||
</View>
|
||||
{!Platform.isTV && <ChromecastSettings />}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { SeerrSettings } from "@/components/settings/Seerr";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function Page() {
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -17,10 +17,10 @@ export default function Page() {
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.seerrServerUrl?.locked === true}
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='px-4'
|
||||
>
|
||||
<SeerrSettings />
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -15,14 +15,24 @@ import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, View } from "react-native";
|
||||
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { 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 { TVFilterButton } from "@/components/tv";
|
||||
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
@@ -36,19 +46,29 @@ import {
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
|
||||
const TV_ITEM_GAP = 16;
|
||||
const TV_SCALE_PADDING = 20;
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const { showOptions } = useTVOptionModal();
|
||||
const { showItemActions } = useTVItemActionModal();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const [orientation, _setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
@@ -56,7 +76,7 @@ const page: React.FC = () => {
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
const { data: collection, isLoading: isCollectionLoading } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
@@ -71,6 +91,46 @@ const page: React.FC = () => {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// TV Filter queries
|
||||
const { data: tvGenreOptions } = useQuery({
|
||||
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
},
|
||||
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||
});
|
||||
|
||||
const { data: tvYearOptions } = useQuery({
|
||||
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
},
|
||||
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||
});
|
||||
|
||||
const { data: tvTagOptions } = useQuery({
|
||||
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
},
|
||||
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: collection?.Name || "" });
|
||||
setSortOrder([SortOrderOption.Ascending]);
|
||||
@@ -87,6 +147,18 @@ const page: React.FC = () => {
|
||||
setSortBy([sortByOption]);
|
||||
}, [navigation, collection]);
|
||||
|
||||
// Calculate columns for TV grid
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (Platform.isTV) {
|
||||
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||
);
|
||||
}
|
||||
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
|
||||
}, [screenWidth, orientation]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
@@ -98,7 +170,7 @@ const page: React.FC = () => {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 18,
|
||||
limit: Platform.isTV ? 36 : 18,
|
||||
startIndex: pageParam,
|
||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||
sortBy: [sortBy[0]],
|
||||
@@ -123,6 +195,7 @@ const page: React.FC = () => {
|
||||
api,
|
||||
user?.Id,
|
||||
collection,
|
||||
collectionId,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
@@ -131,39 +204,40 @@ const page: React.FC = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collectionId,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
@@ -195,7 +269,6 @@ const page: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
{/* <MoviePoster item={item} /> */}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
@@ -203,9 +276,34 @@ const page: React.FC = () => {
|
||||
[orientation],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
const renderTVItem = useCallback(
|
||||
({ item }: { item: BaseItemDto }) => {
|
||||
const handlePress = () => {
|
||||
const navTarget = getItemNavigation(item, "(home)");
|
||||
router.push(navTarget as any);
|
||||
};
|
||||
|
||||
const _insets = useSafeAreaInsets();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginRight: TV_ITEM_GAP,
|
||||
marginBottom: TV_ITEM_GAP,
|
||||
}}
|
||||
>
|
||||
<TVPosterCard
|
||||
item={item}
|
||||
orientation='vertical'
|
||||
onPress={handlePress}
|
||||
onLongPress={() => showItemActions(item)}
|
||||
width={posterSizes.poster}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[router, showItemActions, posterSizes.poster],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
@@ -372,48 +470,315 @@ const page: React.FC = () => {
|
||||
],
|
||||
);
|
||||
|
||||
// TV Filter options - with "All" option for clearable filters
|
||||
const tvGenreFilterOptions = useMemo(
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: selectedGenres.length === 0,
|
||||
},
|
||||
...(tvGenreOptions || []).map((genre) => ({
|
||||
label: genre,
|
||||
value: genre,
|
||||
selected: selectedGenres.includes(genre),
|
||||
})),
|
||||
],
|
||||
[tvGenreOptions, selectedGenres, t],
|
||||
);
|
||||
|
||||
const tvYearFilterOptions = useMemo(
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: selectedYears.length === 0,
|
||||
},
|
||||
...(tvYearOptions || []).map((year) => ({
|
||||
label: String(year),
|
||||
value: String(year),
|
||||
selected: selectedYears.includes(String(year)),
|
||||
})),
|
||||
],
|
||||
[tvYearOptions, selectedYears, t],
|
||||
);
|
||||
|
||||
const tvTagFilterOptions = useMemo(
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: selectedTags.length === 0,
|
||||
},
|
||||
...(tvTagOptions || []).map((tag) => ({
|
||||
label: tag,
|
||||
value: tag,
|
||||
selected: selectedTags.includes(tag),
|
||||
})),
|
||||
],
|
||||
[tvTagOptions, selectedTags, t],
|
||||
);
|
||||
|
||||
const tvSortByOptions = useMemo(
|
||||
(): TVOptionItem<SortByOption>[] =>
|
||||
sortOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: sortBy[0] === option.key,
|
||||
})),
|
||||
[sortBy],
|
||||
);
|
||||
|
||||
const tvSortOrderOptions = useMemo(
|
||||
(): TVOptionItem<SortOrderOption>[] =>
|
||||
sortOrderOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: sortOrder[0] === option.key,
|
||||
})),
|
||||
[sortOrder],
|
||||
);
|
||||
|
||||
// TV Filter handlers using navigation-based modal
|
||||
const handleShowGenreFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.genres"),
|
||||
options: tvGenreFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedGenres([]);
|
||||
} else if (selectedGenres.includes(value)) {
|
||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||
} else {
|
||||
setSelectedGenres([...selectedGenres, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||
|
||||
const handleShowYearFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.years"),
|
||||
options: tvYearFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedYears([]);
|
||||
} else if (selectedYears.includes(value)) {
|
||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||
} else {
|
||||
setSelectedYears([...selectedYears, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||
|
||||
const handleShowTagFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.tags"),
|
||||
options: tvTagFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedTags([]);
|
||||
} else if (selectedTags.includes(value)) {
|
||||
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||
|
||||
const handleShowSortByFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.sort_by"),
|
||||
options: tvSortByOptions,
|
||||
onSelect: (value: SortByOption) => {
|
||||
setSortBy([value]);
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||
|
||||
const handleShowSortOrderFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.sort_order"),
|
||||
options: tvSortOrderOptions,
|
||||
onSelect: (value: SortOrderOption) => {
|
||||
setSortOrder([value]);
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||
|
||||
// TV filter bar state
|
||||
const hasActiveFilters =
|
||||
selectedGenres.length > 0 ||
|
||||
selectedYears.length > 0 ||
|
||||
selectedTags.length > 0;
|
||||
|
||||
const resetAllFilters = useCallback(() => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedTags([]);
|
||||
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
|
||||
|
||||
if (isLoading || isCollectionLoading) {
|
||||
return (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("search.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
// Mobile return
|
||||
if (!Platform.isTV) {
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("search.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TV return with filter bar
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Filter bar */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "nowrap",
|
||||
marginTop: insets.top + 100,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: TV_SCALE_PADDING,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{hasActiveFilters && (
|
||||
<TVFilterButton
|
||||
label=''
|
||||
value={t("library.filters.reset")}
|
||||
onPress={resetAllFilters}
|
||||
hasActiveFilter
|
||||
/>
|
||||
)}
|
||||
<TVFilterButton
|
||||
label={t("library.filters.genres")}
|
||||
value={
|
||||
selectedGenres.length > 0
|
||||
? `${selectedGenres.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowGenreFilter}
|
||||
hasTVPreferredFocus={!hasActiveFilters}
|
||||
hasActiveFilter={selectedGenres.length > 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.years")}
|
||||
value={
|
||||
selectedYears.length > 0
|
||||
? `${selectedYears.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowYearFilter}
|
||||
hasActiveFilter={selectedYears.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.tags")}
|
||||
value={
|
||||
selectedTags.length > 0
|
||||
? `${selectedTags.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowTagFilter}
|
||||
hasActiveFilter={selectedTags.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_by")}
|
||||
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||
onPress={handleShowSortByFilter}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_order")}
|
||||
value={
|
||||
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||
}
|
||||
onPress={handleShowSortOrderFilter}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Grid */}
|
||||
<FlatList
|
||||
key={`${orientation}-${nrOfCols}`}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("search.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderTVItem}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
removeClippedSubviews={false}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={1}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: TV_SCALE_PADDING,
|
||||
paddingRight: TV_SCALE_PADDING,
|
||||
paddingTop: 20,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import { 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,
|
||||
@@ -15,6 +14,10 @@ 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 };
|
||||
const { t } = useTranslation();
|
||||
@@ -24,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,
|
||||
@@ -40,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 (
|
||||
@@ -78,31 +66,46 @@ const Page: React.FC = () => {
|
||||
return (
|
||||
<OfflineModeProvider isOffline={isOffline}>
|
||||
<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 && <ItemContent item={item} itemWithSources={itemWithSources} />}
|
||||
{/* Always render ItemContent - it handles loading state internally on TV */}
|
||||
<ItemContent
|
||||
item={item}
|
||||
itemWithSources={itemWithSources}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {
|
||||
type MovieResult,
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
|
||||
export default function CompanyPage() {
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
const { companyId, image, type } = local as unknown as {
|
||||
companyId: string;
|
||||
@@ -25,12 +25,12 @@ export default function CompanyPage() {
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||
queryKey: ["seerr", "company", type, companyId],
|
||||
queryKey: ["jellyseerr", "company", type, companyId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
page: Number(pageParam),
|
||||
};
|
||||
return seerrApi?.discover(
|
||||
return jellyseerrApi?.discover(
|
||||
`${
|
||||
Number(type) === DiscoverSliderType.NETWORKS
|
||||
? Endpoints.DISCOVER_TV_NETWORK
|
||||
@@ -39,7 +39,7 @@ export default function CompanyPage() {
|
||||
params,
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi && !!companyId,
|
||||
enabled: !!jellyseerrApi && !!companyId,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||
@@ -53,24 +53,25 @@ export default function CompanyPage() {
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap(
|
||||
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
|
||||
(p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||
),
|
||||
"id",
|
||||
) ?? [],
|
||||
[data, isSeerrMovieOrTvResult],
|
||||
[data],
|
||||
);
|
||||
|
||||
const backdrops = useMemo(
|
||||
() =>
|
||||
seerrApi
|
||||
jellyseerrApi
|
||||
? flatData.map((r) =>
|
||||
seerrApi.imageProxy(
|
||||
jellyseerrApi.imageProxy(
|
||||
(r as TvResult | MovieResult).backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[seerrApi, flatData],
|
||||
[jellyseerrApi, flatData],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -91,7 +92,7 @@ export default function CompanyPage() {
|
||||
key={companyId}
|
||||
className='bottom-1 w-1/2'
|
||||
source={{
|
||||
uri: seerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='contain'
|
||||
@@ -100,7 +101,7 @@ export default function CompanyPage() {
|
||||
}}
|
||||
/>
|
||||
}
|
||||
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
||||
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
|
||||
export default function GenrePage() {
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
const { genreId, name, type } = local as unknown as {
|
||||
genreId: string;
|
||||
@@ -20,21 +20,21 @@ export default function GenrePage() {
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["seerr", "genre", type, genreId],
|
||||
queryKey: ["jellyseerr", "company", type, genreId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
page: Number(pageParam),
|
||||
genre: genreId,
|
||||
};
|
||||
|
||||
return seerrApi?.discover(
|
||||
return jellyseerrApi?.discover(
|
||||
type === DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.DISCOVER_MOVIES
|
||||
: Endpoints.DISCOVER_TV,
|
||||
params,
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi && !!genreId,
|
||||
enabled: !!jellyseerrApi && !!genreId,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||
@@ -48,7 +48,8 @@ export default function GenrePage() {
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap(
|
||||
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
|
||||
(p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||
),
|
||||
"id",
|
||||
) ?? [],
|
||||
@@ -57,12 +58,15 @@ export default function GenrePage() {
|
||||
|
||||
const backdrops = useMemo(
|
||||
() =>
|
||||
seerrApi
|
||||
jellyseerrApi
|
||||
? flatData.map((r) =>
|
||||
seerrApi.imageProxy(r.backdropPath, "w1920_and_h800_multi_faces"),
|
||||
jellyseerrApi.imageProxy(
|
||||
r.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[seerrApi, flatData],
|
||||
[jellyseerrApi, flatData],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -87,7 +91,7 @@ export default function GenrePage() {
|
||||
{name}
|
||||
</Text>
|
||||
}
|
||||
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,18 +18,19 @@ import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import Cast from "@/components/jellyseerr/Cast";
|
||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||
import { TVJellyseerrPage } from "@/components/jellyseerr/tv";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { SeerrRatings } from "@/components/Ratings";
|
||||
import Cast from "@/components/seerr/Cast";
|
||||
import DetailFacts from "@/components/seerr/DetailFacts";
|
||||
import RequestModal from "@/components/seerr/RequestModal";
|
||||
import SeerrSeasons from "@/components/series/SeerrSeasons";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import {
|
||||
type IssueType,
|
||||
@@ -52,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();
|
||||
@@ -68,7 +70,7 @@ const Page: React.FC = () => {
|
||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { seerrApi, seerrUser, requestMedia } = useSeerr();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
@@ -83,8 +85,8 @@ const Page: React.FC = () => {
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
enabled: !!seerrApi && !!result && !!result.id,
|
||||
queryKey: ["seerr", "detail", mediaType, result.id],
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
@@ -93,18 +95,21 @@ const Page: React.FC = () => {
|
||||
refetchInterval: 0,
|
||||
queryFn: async () => {
|
||||
return mediaType === MediaType.MOVIE
|
||||
? seerrApi?.movieDetails(result.id!)
|
||||
: seerrApi?.tvDetails(result.id!);
|
||||
? jellyseerrApi?.movieDetails(result.id!)
|
||||
: jellyseerrApi?.tvDetails(result.id!);
|
||||
},
|
||||
});
|
||||
|
||||
const [canRequest, hasAdvancedRequestPermission] =
|
||||
useSeerrCanRequest(details);
|
||||
useJellyseerrCanRequest(details);
|
||||
|
||||
const canManageRequests = useMemo(() => {
|
||||
if (!seerrUser) return false;
|
||||
return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions);
|
||||
}, [seerrUser]);
|
||||
if (!jellyseerrUser) return false;
|
||||
return hasPermission(
|
||||
Permission.MANAGE_REQUESTS,
|
||||
jellyseerrUser.permissions,
|
||||
);
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const pendingRequest = useMemo(() => {
|
||||
return details?.mediaInfo?.requests?.find(
|
||||
@@ -116,27 +121,27 @@ const Page: React.FC = () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
|
||||
try {
|
||||
await seerrApi?.approveRequest(pendingRequest.id);
|
||||
toast.success(t("seerr.toasts.request_approved"));
|
||||
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(t("seerr.toasts.failed_to_approve_request"));
|
||||
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||
console.error("Failed to approve request:", error);
|
||||
}
|
||||
}, [seerrApi, pendingRequest, refetch, t]);
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const handleDeclineRequest = useCallback(async () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
|
||||
try {
|
||||
await seerrApi?.declineRequest(pendingRequest.id);
|
||||
toast.success(t("seerr.toasts.request_declined"));
|
||||
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(t("seerr.toasts.failed_to_decline_request"));
|
||||
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||
console.error("Failed to decline request:", error);
|
||||
}
|
||||
}, [seerrApi, pendingRequest, refetch, t]);
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
@@ -151,7 +156,7 @@ const Page: React.FC = () => {
|
||||
|
||||
const submitIssue = useCallback(() => {
|
||||
if (result.id && issueType && issueMessage && details) {
|
||||
seerrApi
|
||||
jellyseerrApi
|
||||
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||
.then(() => {
|
||||
setIssueType(undefined);
|
||||
@@ -159,7 +164,7 @@ const Page: React.FC = () => {
|
||||
bottomSheetModalRef?.current?.close();
|
||||
});
|
||||
}
|
||||
}, [seerrApi, details, result, issueType, issueMessage]);
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const handleIssueModalDismiss = useCallback(() => {
|
||||
setIssueTypeDropdownOpen(false);
|
||||
@@ -211,7 +216,7 @@ const Page: React.FC = () => {
|
||||
const issueTypeOptionGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("seerr.types"),
|
||||
title: t("jellyseerr.types"),
|
||||
options: Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value]) => ({
|
||||
@@ -262,7 +267,7 @@ const Page: React.FC = () => {
|
||||
height: "100%",
|
||||
}}
|
||||
source={{
|
||||
uri: seerrApi?.imageProxy(
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
result.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
@@ -292,7 +297,7 @@ const Page: React.FC = () => {
|
||||
<View className='px-4'>
|
||||
<View className='flex flex-row justify-between w-full'>
|
||||
<View className='flex flex-col w-56'>
|
||||
<SeerrRatings
|
||||
<JellyserrRatings
|
||||
result={
|
||||
result as
|
||||
| MovieResult
|
||||
@@ -327,7 +332,7 @@ const Page: React.FC = () => {
|
||||
/>
|
||||
) : canRequest ? (
|
||||
<Button color='purple' onPress={request} className='mt-4'>
|
||||
{t("seerr.request_button")}
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
) : (
|
||||
details?.mediaInfo?.jellyfinMediaId && (
|
||||
@@ -350,7 +355,7 @@ const Page: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>
|
||||
{t("seerr.report_issue_button")}
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
@@ -386,12 +391,12 @@ const Page: React.FC = () => {
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||
<Text className='text-sm text-neutral-400'>
|
||||
{t("seerr.requested_by", {
|
||||
{t("jellyseerr.requested_by", {
|
||||
user:
|
||||
pendingRequest.requestedBy?.displayName ||
|
||||
pendingRequest.requestedBy?.username ||
|
||||
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||
t("seerr.unknown_user"),
|
||||
t("jellyseerr.unknown_user"),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -412,7 +417,7 @@ const Page: React.FC = () => {
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("seerr.approve")}</Text>
|
||||
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||
@@ -430,7 +435,7 @@ const Page: React.FC = () => {
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("seerr.decline")}</Text>
|
||||
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -439,7 +444,7 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{mediaType === MediaType.TV && (
|
||||
<SeerrSeasons
|
||||
<JellyseerrSeasons
|
||||
isLoading={isLoading || isFetching}
|
||||
details={details as TvDetails}
|
||||
refetch={refetch}
|
||||
@@ -488,13 +493,13 @@ const Page: React.FC = () => {
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("seerr.whats_wrong")}
|
||||
{t("jellyseerr.whats_wrong")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col w-full'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.issue_type")}
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={issueTypeOptionGroups}
|
||||
@@ -503,11 +508,11 @@ const Page: React.FC = () => {
|
||||
<Text numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: t("seerr.select_an_issue")}
|
||||
: t("jellyseerr.select_an_issue")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.types")}
|
||||
title={t("jellyseerr.types")}
|
||||
open={issueTypeDropdownOpen}
|
||||
onOpenChange={setIssueTypeDropdownOpen}
|
||||
/>
|
||||
@@ -519,7 +524,7 @@ const Page: React.FC = () => {
|
||||
maxLength={254}
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode='always'
|
||||
placeholder={t("seerr.describe_the_issue")}
|
||||
placeholder={t("jellyseerr.describe_the_issue")}
|
||||
placeholderTextColor='#9CA3AF'
|
||||
// Issue with multiline + Textinput inside a portal
|
||||
// https://github.com/callstack/react-native-paper/issues/1668
|
||||
@@ -529,7 +534,7 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||
{t("seerr.submit_button")}
|
||||
{t("jellyseerr.submit_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
@@ -539,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;
|
||||
@@ -5,27 +5,31 @@ import { orderBy, uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
|
||||
export default function PersonPage() {
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { seerrApi, seerrLocale: locale } = useSeerr();
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
|
||||
const { personId } = local as { personId: string };
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["seerr", "person", personId],
|
||||
queryKey: ["jellyseerr", "person", personId],
|
||||
queryFn: async () => ({
|
||||
details: await seerrApi?.personDetails(personId),
|
||||
combinedCredits: await seerrApi?.personCombinedCredits(personId),
|
||||
details: await jellyseerrApi?.personDetails(personId),
|
||||
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
|
||||
}),
|
||||
enabled: !!seerrApi && !!personId,
|
||||
enabled: !!jellyseerrApi && !!personId,
|
||||
});
|
||||
|
||||
const castedRoles: PersonCreditCast[] = useMemo(
|
||||
@@ -42,19 +46,22 @@ export default function PersonPage() {
|
||||
);
|
||||
const backdrops = useMemo(
|
||||
() =>
|
||||
seerrApi
|
||||
jellyseerrApi
|
||||
? castedRoles.map((c) =>
|
||||
seerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"),
|
||||
jellyseerrApi.imageProxy(
|
||||
c.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[seerrApi, data?.combinedCredits],
|
||||
[jellyseerrApi, data?.combinedCredits],
|
||||
);
|
||||
|
||||
return (
|
||||
<ParallaxSlideShow
|
||||
data={castedRoles}
|
||||
images={backdrops}
|
||||
listHeader={t("seerr.appearances")}
|
||||
listHeader={t("jellyseerr.appearances")}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
logo={
|
||||
<Image
|
||||
@@ -62,7 +69,7 @@ export default function PersonPage() {
|
||||
id={data?.details?.id.toString()}
|
||||
className='rounded-full bottom-1'
|
||||
source={{
|
||||
uri: seerrApi?.imageProxy(
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
data?.details?.profilePath,
|
||||
"w600_and_h600_bestv2",
|
||||
),
|
||||
@@ -79,13 +86,16 @@ export default function PersonPage() {
|
||||
<>
|
||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||
<Text className='opacity-50'>
|
||||
{t("seerr.born")}{" "}
|
||||
{t("jellyseerr.born")}{" "}
|
||||
{data?.details?.birthday &&
|
||||
new Date(data.details.birthday).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
new Date(data.details.birthday).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}{" "}
|
||||
| {data?.details?.placeOfBirth}
|
||||
</Text>
|
||||
</>
|
||||
@@ -93,7 +103,7 @@ export default function PersonPage() {
|
||||
MainContent={() => (
|
||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||
)}
|
||||
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, withLayoutContext } from "expo-router";
|
||||
import { Slot, Stack, withLayoutContext } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
@@ -19,6 +20,17 @@ export const Tab = withLayoutContext<
|
||||
>(Navigator);
|
||||
|
||||
const Layout = () => {
|
||||
// On TV, skip the Material Top Tab Navigator and render children directly
|
||||
// The TV version handles its own tab navigation internally
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Slot />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Live TV" }} />
|
||||
|
||||
@@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function page() {
|
||||
if (Platform.isTV) {
|
||||
return <TVLiveTVPage />;
|
||||
}
|
||||
|
||||
return <MobileLiveTVPrograms />;
|
||||
}
|
||||
|
||||
function MobileLiveTVPrograms() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -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,6 +14,7 @@ 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";
|
||||
@@ -61,6 +62,7 @@ const page: React.FC = () => {
|
||||
});
|
||||
},
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
@@ -116,7 +118,8 @@ const page: React.FC = () => {
|
||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||
),
|
||||
staleTime: isOffline ? Infinity : 60,
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
@@ -159,6 +162,19 @@ const page: React.FC = () => {
|
||||
// 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 (
|
||||
<OfflineModeProvider isOffline={isOffline}>
|
||||
<ParallaxScrollView
|
||||
|
||||
@@ -11,20 +11,37 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
import {
|
||||
FlatList,
|
||||
Platform,
|
||||
ScrollView,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { 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 { TVFilterButton, TVFocusablePoster } from "@/components/tv";
|
||||
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
@@ -48,6 +65,13 @@ import {
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
const TV_ITEM_GAP = 20;
|
||||
const TV_HORIZONTAL_PADDING = 60;
|
||||
const _TV_SCALE_PADDING = 20;
|
||||
const TV_PLAYLIST_SQUARE_SIZE = 180;
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams() as {
|
||||
@@ -58,6 +82,8 @@ const Page = () => {
|
||||
};
|
||||
const { libraryId } = searchParams;
|
||||
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
@@ -79,6 +105,49 @@ const Page = () => {
|
||||
const { orientation } = useOrientation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { showOptions } = useTVOptionModal();
|
||||
const { showItemActions } = useTVItemActionModal();
|
||||
|
||||
// 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 +231,10 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (Platform.isTV) {
|
||||
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||
return 1;
|
||||
}
|
||||
if (screenWidth < 300) return 2;
|
||||
if (screenWidth < 500) return 3;
|
||||
if (screenWidth < 800) return 5;
|
||||
@@ -213,6 +286,8 @@ const Page = () => {
|
||||
itemType = "Video";
|
||||
} else if (library.CollectionType === "musicvideos") {
|
||||
itemType = "MusicVideo";
|
||||
} else if (library.CollectionType === "playlists") {
|
||||
itemType = "Playlist";
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
@@ -232,6 +307,9 @@ const Page = () => {
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||
includeItemTypes: itemType ? [itemType] : undefined,
|
||||
...(Platform.isTV && library.CollectionType === "playlists"
|
||||
? { mediaTypes: ["Video"] }
|
||||
: {}),
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
@@ -322,7 +400,88 @@ const Page = () => {
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation],
|
||||
[orientation, nrOfCols],
|
||||
);
|
||||
|
||||
const renderTVItem = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
const handlePress = () => {
|
||||
if (item.Type === "Playlist") {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||
params: { libraryId: item.Id! },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const navTarget = getItemNavigation(item, "(libraries)");
|
||||
router.push(navTarget as any);
|
||||
};
|
||||
|
||||
// Special rendering for Playlist items (square thumbnails)
|
||||
if (item.Type === "Playlist") {
|
||||
const playlistImageUrl = getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
width: TV_PLAYLIST_SQUARE_SIZE * 2,
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: TV_PLAYLIST_SQUARE_SIZE,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TVFocusablePoster
|
||||
onPress={handlePress}
|
||||
onLongPress={() => showItemActions(item)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: TV_PLAYLIST_SQUARE_SIZE,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={playlistImageUrl ? { uri: playlistImageUrl } : null}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
</View>
|
||||
</TVFocusablePoster>
|
||||
<View style={{ marginTop: 12, alignItems: "center" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TVPosterCard
|
||||
key={item.Id}
|
||||
item={item}
|
||||
orientation='vertical'
|
||||
onPress={handlePress}
|
||||
onLongPress={() => showItemActions(item)}
|
||||
width={posterSizes.poster}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[router, showItemActions, api, typography],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
@@ -509,6 +668,188 @@ const Page = () => {
|
||||
],
|
||||
);
|
||||
|
||||
// TV Filter bar header
|
||||
const hasActiveFilters =
|
||||
selectedGenres.length > 0 ||
|
||||
selectedYears.length > 0 ||
|
||||
selectedTags.length > 0 ||
|
||||
filterBy.length > 0;
|
||||
|
||||
const resetAllFilters = useCallback(() => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedTags([]);
|
||||
_setFilterBy([]);
|
||||
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
|
||||
|
||||
// TV Filter options - with "All" option for clearable filters
|
||||
const tvGenreFilterOptions = useMemo(
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: selectedGenres.length === 0,
|
||||
},
|
||||
...(tvGenreOptions || []).map((genre) => ({
|
||||
label: genre,
|
||||
value: genre,
|
||||
selected: selectedGenres.includes(genre),
|
||||
})),
|
||||
],
|
||||
[tvGenreOptions, selectedGenres, t],
|
||||
);
|
||||
|
||||
const tvYearFilterOptions = useMemo(
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: selectedYears.length === 0,
|
||||
},
|
||||
...(tvYearOptions || []).map((year) => ({
|
||||
label: String(year),
|
||||
value: String(year),
|
||||
selected: selectedYears.includes(String(year)),
|
||||
})),
|
||||
],
|
||||
[tvYearOptions, selectedYears, t],
|
||||
);
|
||||
|
||||
const tvTagFilterOptions = useMemo(
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: selectedTags.length === 0,
|
||||
},
|
||||
...(tvTagOptions || []).map((tag) => ({
|
||||
label: tag,
|
||||
value: tag,
|
||||
selected: selectedTags.includes(tag),
|
||||
})),
|
||||
],
|
||||
[tvTagOptions, selectedTags, t],
|
||||
);
|
||||
|
||||
const tvSortByOptions = useMemo(
|
||||
(): TVOptionItem<SortByOption>[] =>
|
||||
sortOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: sortBy[0] === option.key,
|
||||
})),
|
||||
[sortBy],
|
||||
);
|
||||
|
||||
const tvSortOrderOptions = useMemo(
|
||||
(): TVOptionItem<SortOrderOption>[] =>
|
||||
sortOrderOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: sortOrder[0] === option.key,
|
||||
})),
|
||||
[sortOrder],
|
||||
);
|
||||
|
||||
const tvFilterByOptions = useMemo(
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: filterBy.length === 0,
|
||||
},
|
||||
...generalFilters.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: filterBy.includes(option.key),
|
||||
})),
|
||||
],
|
||||
[filterBy, generalFilters, t],
|
||||
);
|
||||
|
||||
// TV Filter handlers using navigation-based modal
|
||||
const handleShowGenreFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.genres"),
|
||||
options: tvGenreFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedGenres([]);
|
||||
} else if (selectedGenres.includes(value)) {
|
||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||
} else {
|
||||
setSelectedGenres([...selectedGenres, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||
|
||||
const handleShowYearFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.years"),
|
||||
options: tvYearFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedYears([]);
|
||||
} else if (selectedYears.includes(value)) {
|
||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||
} else {
|
||||
setSelectedYears([...selectedYears, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||
|
||||
const handleShowTagFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.tags"),
|
||||
options: tvTagFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedTags([]);
|
||||
} else if (selectedTags.includes(value)) {
|
||||
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||
|
||||
const handleShowSortByFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.sort_by"),
|
||||
options: tvSortByOptions,
|
||||
onSelect: (value: SortByOption) => {
|
||||
setSortBy([value]);
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||
|
||||
const handleShowSortOrderFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.sort_order"),
|
||||
options: tvSortOrderOptions,
|
||||
onSelect: (value: SortOrderOption) => {
|
||||
setSortOrder([value]);
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||
|
||||
const handleShowFilterByFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.filter_by"),
|
||||
options: tvFilterByOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
_setFilterBy([]);
|
||||
} else {
|
||||
setFilter([value as FilterByOption]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading || isLibraryLoading)
|
||||
@@ -518,43 +859,176 @@ const Page = () => {
|
||||
</View>
|
||||
);
|
||||
|
||||
// Mobile return
|
||||
if (!Platform.isTV) {
|
||||
return (
|
||||
<FlashList
|
||||
key={orientation}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("library.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={1}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TV return with filter bar
|
||||
return (
|
||||
<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) {
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 100,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||
}}
|
||||
onScroll={({ nativeEvent }) => {
|
||||
// Load more when near bottom
|
||||
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||
const isNearBottom =
|
||||
layoutMeasurement.height + contentOffset.y >=
|
||||
contentSize.height - 500;
|
||||
if (isNearBottom && hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={1}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
{/* Filter bar */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "nowrap",
|
||||
justifyContent: "center",
|
||||
paddingBottom: 24,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{hasActiveFilters && (
|
||||
<TVFilterButton
|
||||
label=''
|
||||
value={t("library.filters.reset")}
|
||||
onPress={resetAllFilters}
|
||||
hasActiveFilter
|
||||
/>
|
||||
)}
|
||||
<TVFilterButton
|
||||
label={t("library.filters.genres")}
|
||||
value={
|
||||
selectedGenres.length > 0
|
||||
? `${selectedGenres.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowGenreFilter}
|
||||
hasTVPreferredFocus={!hasActiveFilters}
|
||||
hasActiveFilter={selectedGenres.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.years")}
|
||||
value={
|
||||
selectedYears.length > 0
|
||||
? `${selectedYears.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowYearFilter}
|
||||
hasActiveFilter={selectedYears.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.tags")}
|
||||
value={
|
||||
selectedTags.length > 0
|
||||
? `${selectedTags.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowTagFilter}
|
||||
hasActiveFilter={selectedTags.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_by")}
|
||||
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||
onPress={handleShowSortByFilter}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_order")}
|
||||
value={
|
||||
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||
}
|
||||
onPress={handleShowSortOrderFilter}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.filter_by")}
|
||||
value={
|
||||
filterBy.length > 0
|
||||
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowFilterByFilter}
|
||||
hasActiveFilter={filterBy.length > 0}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Grid with flexWrap */}
|
||||
{flatData.length === 0 ? (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingTop: 100,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||
{t("library.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: TV_ITEM_GAP,
|
||||
}}
|
||||
>
|
||||
{flatData.map((item) => renderTVItem(item))}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isFetching && (
|
||||
<View style={{ paddingVertical: 20 }}>
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,109 +1,11 @@
|
||||
import {
|
||||
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 />;
|
||||
}
|
||||
|
||||
@@ -33,17 +33,17 @@ export default function SearchLayout() {
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='seerr/page' options={commonScreenOptions} />
|
||||
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
||||
<Stack.Screen
|
||||
name='seerr/person/[personId]'
|
||||
name='jellyseerr/person/[personId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='seerr/company/[companyId]'
|
||||
name='jellyseerr/company/[companyId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='seerr/genre/[genreId]'
|
||||
name='jellyseerr/genre/[genreId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -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 { 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,26 +23,36 @@ 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,
|
||||
JellyserrIndexPage,
|
||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import {
|
||||
SeerrIndexPage,
|
||||
SeerrSearchSort,
|
||||
} from "@/components/seerr/SeerrIndexPage";
|
||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
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";
|
||||
@@ -55,10 +66,13 @@ const exampleSearches = [
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
export default function SearchPage() {
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { showItemActions } = useTVItemActionModal();
|
||||
const segments = useSegments();
|
||||
const from = (segments as string[])[2] || "(search)";
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
@@ -93,11 +107,16 @@ export default function SearchPage() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { seerrApi } = useSeerr();
|
||||
const [seerrOrderBy, setSeerrOrderBy] = useState<SeerrSearchSort>(
|
||||
SeerrSearchSort[SeerrSearchSort.DEFAULT] as unknown as SeerrSearchSort,
|
||||
);
|
||||
const [seerrSortOrder, setSeerrSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||
useState<JellyseerrSearchSort>(
|
||||
JellyseerrSearchSort[
|
||||
JellyseerrSearchSort.DEFAULT
|
||||
] as unknown as JellyseerrSearchSort,
|
||||
);
|
||||
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
||||
"asc" | "desc"
|
||||
>("desc");
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
@@ -194,9 +213,7 @@ export default function SearchPage() {
|
||||
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=")}`;
|
||||
|
||||
@@ -435,6 +452,180 @@ export default function SearchPage() {
|
||||
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}
|
||||
onItemLongPress={showItemActions}
|
||||
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'
|
||||
@@ -445,31 +636,11 @@ export default function SearchPage() {
|
||||
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 }}
|
||||
>
|
||||
{seerrApi && (
|
||||
{jellyseerrApi && (
|
||||
<View className='pl-4 pr-4 flex flex-row'>
|
||||
<SearchTabButtons
|
||||
searchType={searchType}
|
||||
@@ -483,10 +654,10 @@ export default function SearchPage() {
|
||||
<DiscoverFilters
|
||||
searchFilterId={searchFilterId}
|
||||
orderFilterId={orderFilterId}
|
||||
seerrOrderBy={seerrOrderBy}
|
||||
setSeerrOrderBy={setSeerrOrderBy}
|
||||
seerrSortOrder={seerrSortOrder}
|
||||
setSeerrSortOrder={setSeerrSortOrder}
|
||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -749,10 +920,10 @@ export default function SearchPage() {
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<SeerrIndexPage
|
||||
<JellyserrIndexPage
|
||||
searchQuery={debouncedSearch}
|
||||
sortType={seerrOrderBy}
|
||||
order={seerrSortOrder}
|
||||
sortType={jellyseerrOrderBy}
|
||||
order={jellyseerrSortOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
21
app/(auth)/(tabs)/(settings)/_layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.settings"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
5
app/(auth)/(tabs)/(settings)/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv";
|
||||
|
||||
export default function SettingsTabScreen() {
|
||||
return <SettingsTV />;
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
@@ -16,11 +18,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 { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import {
|
||||
useDeleteWatchlist,
|
||||
useRemoveFromWatchlist,
|
||||
@@ -32,9 +41,15 @@ import {
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const TV_ITEM_GAP = 20;
|
||||
const TV_HORIZONTAL_PADDING = 60;
|
||||
|
||||
export default function WatchlistDetailScreen() {
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { showItemActions } = useTVItemActionModal();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||
@@ -47,6 +62,8 @@ export default function WatchlistDetailScreen() {
|
||||
: undefined;
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||
if (Platform.isTV) return 1;
|
||||
if (screenWidth < 300) return 2;
|
||||
if (screenWidth < 500) return 3;
|
||||
if (screenWidth < 800) return 5;
|
||||
@@ -153,6 +170,28 @@ export default function WatchlistDetailScreen() {
|
||||
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||
);
|
||||
|
||||
const renderTVItem = useCallback(
|
||||
(item: BaseItemDto, index: number) => {
|
||||
const handlePress = () => {
|
||||
const navigation = getItemNavigation(item, "(watchlists)");
|
||||
router.push(navigation as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<TVPosterCard
|
||||
key={item.Id}
|
||||
item={item}
|
||||
orientation='vertical'
|
||||
onPress={handlePress}
|
||||
onLongPress={() => showItemActions(item)}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
width={posterSizes.poster}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[router, showItemActions, posterSizes.poster],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
@@ -266,6 +305,120 @@ export default function WatchlistDetailScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// TV layout with ScrollView + flexWrap
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 100,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginBottom: 32,
|
||||
paddingBottom: 24,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "rgba(255,255,255,0.1)",
|
||||
}}
|
||||
>
|
||||
{watchlist.description && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{watchlist.description}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<Ionicons name='film-outline' size={20} color='#9ca3af' />
|
||||
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||
{items?.length ?? 0}{" "}
|
||||
{(items?.length ?? 0) === 1
|
||||
? t("watchlists.item")
|
||||
: t("watchlists.items")}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={
|
||||
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||
}
|
||||
size={20}
|
||||
color='#9ca3af'
|
||||
/>
|
||||
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||
{watchlist.isPublic
|
||||
? t("watchlists.public")
|
||||
: t("watchlists.private")}
|
||||
</Text>
|
||||
</View>
|
||||
{!isOwner && (
|
||||
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
|
||||
{t("watchlists.by_owner")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Grid with flexWrap */}
|
||||
{!items || items.length === 0 ? (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingTop: 100,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "#9CA3AF",
|
||||
textAlign: "center",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{t("watchlists.empty_watchlist")}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: TV_ITEM_GAP,
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => renderTVItem(item, index))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile layout with FlashList
|
||||
return (
|
||||
<FlashList
|
||||
key={orientation}
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function WatchlistsLayout() {
|
||||
name='[watchlistId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -51,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" },
|
||||
@@ -62,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" },
|
||||
|
||||
@@ -11,12 +11,19 @@ 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 { useTVBackHandler } from "@/hooks/useTVBackHandler";
|
||||
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<
|
||||
@@ -30,6 +37,9 @@ export default function TabLayout() {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Handle TV back button - prevent app exit when at root
|
||||
useTVBackHandler();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
@@ -117,6 +127,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 />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type BaseItemDto,
|
||||
type MediaSourceInfo,
|
||||
type MediaStream,
|
||||
PlaybackOrder,
|
||||
PlaybackProgressInfo,
|
||||
RepeatMode,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { File } from "expo-file-system";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
@@ -20,6 +22,7 @@ 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 {
|
||||
@@ -43,19 +46,21 @@ import {
|
||||
} from "@/modules";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
getMpvAudioId,
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||
@@ -71,6 +76,9 @@ export default function page() {
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [isPipMode, setIsPipMode] = useState(false);
|
||||
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
||||
"default",
|
||||
);
|
||||
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
@@ -81,6 +89,12 @@ export default function page() {
|
||||
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);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
@@ -93,6 +107,9 @@ export default function page() {
|
||||
// when data updates, only when the provider initializes
|
||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||
|
||||
// Inactivity timer controls (TV only)
|
||||
const { pauseInactivityTimer, resumeInactivityTimer } = useInactivity();
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
@@ -123,7 +140,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
|
||||
@@ -146,6 +162,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) {
|
||||
@@ -157,6 +177,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,
|
||||
@@ -202,7 +233,12 @@ export default function page() {
|
||||
setDownloadedItem(data);
|
||||
}
|
||||
} else {
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
// Guard against api being null (e.g., during logout)
|
||||
if (!api) {
|
||||
setItemStatus({ isLoading: false, isError: false });
|
||||
return;
|
||||
}
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
@@ -236,6 +272,7 @@ export default function page() {
|
||||
mediaSource: MediaSourceInfo;
|
||||
sessionId: string;
|
||||
url: string;
|
||||
requiredHttpHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
const [stream, setStream] = useState<Stream | null>(null);
|
||||
@@ -244,15 +281,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;
|
||||
@@ -270,12 +310,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
|
||||
@@ -294,25 +334,30 @@ export default function page() {
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
});
|
||||
if (!res) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
if (!res) return null;
|
||||
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.failed_to_get_stream_url"),
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
result = { mediaSource, sessionId, url };
|
||||
result = { mediaSource, sessionId, url, requiredHttpHeaders };
|
||||
}
|
||||
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,
|
||||
@@ -386,7 +431,9 @@ export default function page() {
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
revalidateProgressCache();
|
||||
}, [videoRef, reportPlaybackStopped, progress]);
|
||||
// Resume inactivity timer when leaving player (TV only)
|
||||
resumeInactivityTimer();
|
||||
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
@@ -553,6 +600,13 @@ export default function page() {
|
||||
autoplay: true,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
// Pass cache/buffer settings from user preferences
|
||||
cacheConfig: {
|
||||
enabled: settings.mpvCacheEnabled,
|
||||
cacheSeconds: settings.mpvCacheSeconds,
|
||||
maxBytes: settings.mpvDemuxerMaxBytes,
|
||||
maxBackBytes: settings.mpvDemuxerMaxBackBytes,
|
||||
},
|
||||
};
|
||||
|
||||
// Add external subtitles only for online playback
|
||||
@@ -560,17 +614,32 @@ export default function page() {
|
||||
source.externalSubtitles = externalSubs;
|
||||
}
|
||||
|
||||
// Add auth headers only for online streaming (not for local file:// URLs)
|
||||
if (!offline && api?.accessToken) {
|
||||
source.headers = {
|
||||
Authorization: `MediaBrowser Token="${api.accessToken}"`,
|
||||
};
|
||||
// Add headers for online streaming (not for local file:// URLs)
|
||||
if (!offline) {
|
||||
const headers: Record<string, string> = {};
|
||||
const isRemoteStream =
|
||||
mediaSource?.IsRemote && mediaSource?.Protocol === "Http";
|
||||
|
||||
// Add auth header only for Jellyfin API requests (not for external/remote streams)
|
||||
if (api?.accessToken && !isRemoteStream) {
|
||||
headers.Authorization = `MediaBrowser Token="${api.accessToken}"`;
|
||||
}
|
||||
|
||||
// Add any required headers from the media source (e.g., for external/remote streams)
|
||||
if (stream?.requiredHttpHeaders) {
|
||||
Object.assign(headers, stream.requiredHttpHeaders);
|
||||
}
|
||||
|
||||
if (Object.keys(headers).length > 0) {
|
||||
source.headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
return source;
|
||||
}, [
|
||||
stream?.url,
|
||||
stream?.mediaSource,
|
||||
stream?.requiredHttpHeaders,
|
||||
item?.UserData?.PlaybackPositionTicks,
|
||||
playbackPositionFromUrl,
|
||||
api?.basePath,
|
||||
@@ -578,6 +647,10 @@ export default function page() {
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
settings.mpvCacheEnabled,
|
||||
settings.mpvCacheSeconds,
|
||||
settings.mpvDemuxerMaxBytes,
|
||||
settings.mpvDemuxerMaxBackBytes,
|
||||
]);
|
||||
|
||||
const volumeUpCb = useCallback(async () => {
|
||||
@@ -668,6 +741,8 @@ export default function page() {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
setHasPlaybackStarted(true);
|
||||
// Pause inactivity timer during playback (TV only)
|
||||
pauseInactivityTimer();
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
@@ -679,6 +754,8 @@ export default function page() {
|
||||
|
||||
if (isPaused) {
|
||||
setIsPlaying(false);
|
||||
// Resume inactivity timer when paused (TV only)
|
||||
resumeInactivityTimer();
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
@@ -692,7 +769,13 @@ export default function page() {
|
||||
setIsBuffering(isLoading);
|
||||
}
|
||||
},
|
||||
[playbackManager, item?.Id, progress],
|
||||
[
|
||||
playbackManager,
|
||||
item?.Id,
|
||||
progress,
|
||||
pauseInactivityTimer,
|
||||
resumeInactivityTimer,
|
||||
],
|
||||
);
|
||||
|
||||
/** PiP handler for MPV */
|
||||
@@ -734,6 +817,55 @@ 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);
|
||||
@@ -823,6 +955,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;
|
||||
@@ -842,14 +1077,27 @@ export default function page() {
|
||||
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
|
||||
}
|
||||
if (settings.mpvSubtitleFontSize !== undefined) {
|
||||
await videoRef.current?.setSubtitleFontSize?.(
|
||||
settings.mpvSubtitleFontSize,
|
||||
// Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation)
|
||||
// mpv uses #RRGGBBAA format (alpha last, same as CSS)
|
||||
if (settings.mpvSubtitleBackgroundEnabled) {
|
||||
const opacity = settings.mpvSubtitleBackgroundOpacity ?? 75;
|
||||
const alphaHex = Math.round((opacity / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
.toUpperCase();
|
||||
// Enable background-box mode (required for sub-back-color to work)
|
||||
await videoRef.current?.setSubtitleBorderStyle?.("background-box");
|
||||
await videoRef.current?.setSubtitleBackgroundColor?.(
|
||||
`#000000${alphaHex}`,
|
||||
);
|
||||
}
|
||||
// Apply subtitle size from general settings
|
||||
if (settings.subtitleSize) {
|
||||
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
|
||||
// Force override ASS subtitle styles so background shows on styled subtitles
|
||||
await videoRef.current?.setSubtitleAssOverride?.("force");
|
||||
} else {
|
||||
// Restore default outline-and-shadow style
|
||||
await videoRef.current?.setSubtitleBorderStyle?.("outline-and-shadow");
|
||||
await videoRef.current?.setSubtitleBackgroundColor?.("#00000000");
|
||||
// Restore default ASS behavior (keep original styles)
|
||||
await videoRef.current?.setSubtitleAssOverride?.("no");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -870,6 +1118,28 @@ export default function page() {
|
||||
applyInitialPlaybackSpeed();
|
||||
}, [isVideoLoaded, initialPlaybackSpeed]);
|
||||
|
||||
// TV only: Pre-load locally downloaded subtitles when video loads
|
||||
// This adds them to MPV's track list without auto-selecting them
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV || !isVideoLoaded || !videoRef.current || !itemId)
|
||||
return;
|
||||
|
||||
const preloadLocalSubtitles = async () => {
|
||||
const localSubs = getSubtitlesForItem(itemId);
|
||||
for (const sub of localSubs) {
|
||||
// Verify file still exists (cache may have been cleared)
|
||||
const subtitleFile = new File(sub.filePath);
|
||||
if (!subtitleFile.exists) {
|
||||
continue;
|
||||
}
|
||||
// Add subtitle file to MPV without selecting it (select: false)
|
||||
await videoRef.current?.addSubtitleFile?.(sub.filePath, false);
|
||||
}
|
||||
};
|
||||
|
||||
preloadLocalSubtitles();
|
||||
}, [isVideoLoaded, itemId]);
|
||||
|
||||
// Show error UI first, before checking loading/missing‐data
|
||||
if (itemStatus.isError || streamStatus.isError) {
|
||||
return (
|
||||
@@ -961,36 +1231,72 @@ export default function page() {
|
||||
</View>
|
||||
)}
|
||||
</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}
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onZoomToggle={handleZoomToggle}
|
||||
api={api}
|
||||
downloadedFiles={downloadedFiles}
|
||||
playbackSpeed={currentPlaybackSpeed}
|
||||
setPlaybackSpeed={handleSetPlaybackSpeed}
|
||||
showTechnicalInfo={showTechnicalInfo}
|
||||
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
||||
getTechnicalInfo={getTechnicalInfo}
|
||||
playMethod={playMethod}
|
||||
transcodeReasons={transcodeReasons}
|
||||
/>
|
||||
)}
|
||||
{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}
|
||||
downloadedFiles={downloadedFiles}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</VideoProvider>
|
||||
</PlayerProvider>
|
||||
|
||||
171
app/(auth)/tv-option-modal.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVOptionCard } from "@/components/tv";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
export default function TVOptionModal() {
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvOptionModalAtom);
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const firstCardRef = useRef<View>(null);
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
if (!modalState?.options) return 0;
|
||||
const idx = modalState.options.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [modalState?.options]);
|
||||
|
||||
// Animate in on mount and cleanup atom on unmount
|
||||
useEffect(() => {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(200);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
// Delay focus setup to allow layout
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// Clear the atom on unmount to prevent stale callbacks from being retained
|
||||
store.set(tvOptionModalAtom, null);
|
||||
};
|
||||
}, [overlayOpacity, sheetTranslateY]);
|
||||
|
||||
// Request focus on the first card when ready
|
||||
useEffect(() => {
|
||||
if (isReady && firstCardRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
const handleSelect = (value: any) => {
|
||||
modalState?.onSelect(value);
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
};
|
||||
|
||||
// If no modal state, just go back (shouldn't happen in normal usage)
|
||||
if (!modalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, options, cardWidth = 160, cardHeight = 75 } = modalState;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={index}
|
||||
ref={
|
||||
index === initialSelectedIndex ? firstCardRef : undefined
|
||||
}
|
||||
label={option.label}
|
||||
sublabel={option.sublabel}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
});
|
||||
496
app/(auth)/tv-request-modal.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
|
||||
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
||||
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||
import type {
|
||||
QualityProfile,
|
||||
RootFolder,
|
||||
Tag,
|
||||
} from "@/utils/jellyseerr/server/api/servarr/base";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
export default function TVRequestModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvRequestModalAtom);
|
||||
const { t } = useTranslation();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||
mediaId: modalState?.id ? Number(modalState.id) : 0,
|
||||
mediaType: modalState?.mediaType,
|
||||
userId: jellyseerrUser?.id,
|
||||
});
|
||||
|
||||
const [activeSelector, setActiveSelector] = useState<
|
||||
"profile" | "folder" | "user" | null
|
||||
>(null);
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
// Animate in on mount
|
||||
useEffect(() => {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(200);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
store.set(tvRequestModalAtom, null);
|
||||
};
|
||||
}, [overlayOpacity, sheetTranslateY]);
|
||||
|
||||
const { data: serviceSettings } = useQuery({
|
||||
queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"],
|
||||
queryFn: async () =>
|
||||
jellyseerrApi?.service(
|
||||
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
|
||||
),
|
||||
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
|
||||
});
|
||||
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ["jellyseerr", "users"],
|
||||
queryFn: async () =>
|
||||
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
|
||||
});
|
||||
|
||||
const defaultService = useMemo(
|
||||
() => serviceSettings?.find?.((v) => v.isDefault),
|
||||
[serviceSettings],
|
||||
);
|
||||
|
||||
const { data: defaultServiceDetails } = useQuery({
|
||||
queryKey: [
|
||||
"jellyseerr",
|
||||
"request",
|
||||
modalState?.mediaType,
|
||||
"service",
|
||||
"details",
|
||||
defaultService?.id,
|
||||
],
|
||||
queryFn: async () => {
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
serverId: defaultService?.id,
|
||||
}));
|
||||
return jellyseerrApi?.serviceDetails(
|
||||
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
|
||||
defaultService!.id,
|
||||
);
|
||||
},
|
||||
enabled:
|
||||
!!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState,
|
||||
});
|
||||
|
||||
const defaultProfile: QualityProfile | undefined = useMemo(
|
||||
() =>
|
||||
defaultServiceDetails?.profiles.find(
|
||||
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
|
||||
),
|
||||
[defaultServiceDetails],
|
||||
);
|
||||
|
||||
const defaultFolder: RootFolder | undefined = useMemo(
|
||||
() =>
|
||||
defaultServiceDetails?.rootFolders.find(
|
||||
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
|
||||
),
|
||||
[defaultServiceDetails],
|
||||
);
|
||||
|
||||
const defaultTags: Tag[] = useMemo(() => {
|
||||
return (
|
||||
defaultServiceDetails?.tags.filter((t) =>
|
||||
defaultServiceDetails?.server.activeTags?.includes(t.id),
|
||||
) ?? []
|
||||
);
|
||||
}, [defaultServiceDetails]);
|
||||
|
||||
const pathTitleExtractor = (item: RootFolder) =>
|
||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||
|
||||
// Option builders
|
||||
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
|
||||
() =>
|
||||
defaultServiceDetails?.profiles.map((profile) => ({
|
||||
label: profile.name,
|
||||
value: profile.id,
|
||||
selected:
|
||||
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
|
||||
})) || [],
|
||||
[
|
||||
defaultServiceDetails?.profiles,
|
||||
defaultProfile,
|
||||
requestOverrides.profileId,
|
||||
],
|
||||
);
|
||||
|
||||
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
|
||||
() =>
|
||||
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||
label: pathTitleExtractor(folder),
|
||||
value: folder.path,
|
||||
selected:
|
||||
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
|
||||
})) || [],
|
||||
[
|
||||
defaultServiceDetails?.rootFolders,
|
||||
defaultFolder,
|
||||
requestOverrides.rootFolder,
|
||||
],
|
||||
);
|
||||
|
||||
const userOptions: TVOptionItem<number>[] = useMemo(
|
||||
() =>
|
||||
users?.map((user) => ({
|
||||
label: user.displayName,
|
||||
value: user.id,
|
||||
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||
})) || [],
|
||||
[users, jellyseerrUser, requestOverrides.userId],
|
||||
);
|
||||
|
||||
const tagItems = useMemo(() => {
|
||||
return (
|
||||
defaultServiceDetails?.tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
selected:
|
||||
requestOverrides.tags?.includes(tag.id) ||
|
||||
defaultTags.some((dt) => dt.id === tag.id),
|
||||
})) ?? []
|
||||
);
|
||||
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
|
||||
|
||||
// Selected display values
|
||||
const selectedProfileName = useMemo(() => {
|
||||
const profile = defaultServiceDetails?.profiles.find(
|
||||
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
|
||||
);
|
||||
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
|
||||
}, [
|
||||
defaultServiceDetails?.profiles,
|
||||
requestOverrides.profileId,
|
||||
defaultProfile,
|
||||
t,
|
||||
]);
|
||||
|
||||
const selectedFolderName = useMemo(() => {
|
||||
const folder = defaultServiceDetails?.rootFolders.find(
|
||||
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
|
||||
);
|
||||
return folder
|
||||
? pathTitleExtractor(folder)
|
||||
: defaultFolder
|
||||
? pathTitleExtractor(defaultFolder)
|
||||
: t("jellyseerr.select");
|
||||
}, [
|
||||
defaultServiceDetails?.rootFolders,
|
||||
requestOverrides.rootFolder,
|
||||
defaultFolder,
|
||||
t,
|
||||
]);
|
||||
|
||||
const selectedUserName = useMemo(() => {
|
||||
const user = users?.find(
|
||||
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
|
||||
);
|
||||
return (
|
||||
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
|
||||
);
|
||||
}, [users, requestOverrides.userId, jellyseerrUser, t]);
|
||||
|
||||
// Handlers
|
||||
const handleProfileChange = useCallback((profileId: number) => {
|
||||
setRequestOverrides((prev) => ({ ...prev, profileId }));
|
||||
setActiveSelector(null);
|
||||
}, []);
|
||||
|
||||
const handleFolderChange = useCallback((rootFolder: string) => {
|
||||
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
|
||||
setActiveSelector(null);
|
||||
}, []);
|
||||
|
||||
const handleUserChange = useCallback((userId: number) => {
|
||||
setRequestOverrides((prev) => ({ ...prev, userId }));
|
||||
setActiveSelector(null);
|
||||
}, []);
|
||||
|
||||
const handleTagToggle = useCallback(
|
||||
(tagId: number) => {
|
||||
setRequestOverrides((prev) => {
|
||||
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||
const hasTag = currentTags.includes(tagId);
|
||||
return {
|
||||
...prev,
|
||||
tags: hasTag
|
||||
? currentTags.filter((id) => id !== tagId)
|
||||
: [...currentTags, tagId],
|
||||
};
|
||||
});
|
||||
},
|
||||
[defaultTags],
|
||||
);
|
||||
|
||||
const handleRequest = useCallback(() => {
|
||||
if (!modalState) return;
|
||||
|
||||
const body = {
|
||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||
profileId: defaultProfile?.id,
|
||||
rootFolder: defaultFolder?.path,
|
||||
tags: defaultTags.map((t) => t.id),
|
||||
...modalState.requestBody,
|
||||
...requestOverrides,
|
||||
};
|
||||
|
||||
const seasonTitle =
|
||||
modalState.requestBody?.seasons?.length === 1
|
||||
? t("jellyseerr.season_number", {
|
||||
season_number: modalState.requestBody.seasons[0],
|
||||
})
|
||||
: modalState.requestBody?.seasons &&
|
||||
modalState.requestBody.seasons.length > 1
|
||||
? t("jellyseerr.season_all")
|
||||
: undefined;
|
||||
|
||||
requestMedia(
|
||||
seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title,
|
||||
body,
|
||||
() => {
|
||||
modalState.onRequested();
|
||||
router.back();
|
||||
},
|
||||
);
|
||||
}, [
|
||||
modalState,
|
||||
requestOverrides,
|
||||
defaultProfile,
|
||||
defaultFolder,
|
||||
defaultTags,
|
||||
defaultService,
|
||||
defaultServiceDetails,
|
||||
requestMedia,
|
||||
router,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (!modalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDataLoaded = defaultService && defaultServiceDetails && users;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||
{t("jellyseerr.advanced")}
|
||||
</Text>
|
||||
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||
{modalState.title}
|
||||
</Text>
|
||||
|
||||
{isDataLoaded && isReady ? (
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.optionsContainer}>
|
||||
<TVRequestOptionRow
|
||||
label={t("jellyseerr.quality_profile")}
|
||||
value={selectedProfileName}
|
||||
onPress={() => setActiveSelector("profile")}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
<TVRequestOptionRow
|
||||
label={t("jellyseerr.root_folder")}
|
||||
value={selectedFolderName}
|
||||
onPress={() => setActiveSelector("folder")}
|
||||
/>
|
||||
<TVRequestOptionRow
|
||||
label={t("jellyseerr.request_as")}
|
||||
value={selectedUserName}
|
||||
onPress={() => setActiveSelector("user")}
|
||||
/>
|
||||
|
||||
{tagItems.length > 0 && (
|
||||
<TVToggleOptionRow
|
||||
label={t("jellyseerr.tags")}
|
||||
items={tagItems}
|
||||
onToggle={handleTagToggle}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>{t("common.loading")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isReady && (
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVButton
|
||||
onPress={handleRequest}
|
||||
variant='secondary'
|
||||
disabled={!isDataLoaded}
|
||||
>
|
||||
<Ionicons
|
||||
name='add'
|
||||
size={22}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.buttonText,
|
||||
{ fontSize: typography.callout },
|
||||
]}
|
||||
>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
|
||||
{/* Sub-selectors */}
|
||||
<TVOptionSelector
|
||||
visible={activeSelector === "profile"}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
options={qualityProfileOptions}
|
||||
onSelect={handleProfileChange}
|
||||
onClose={() => setActiveSelector(null)}
|
||||
cancelLabel={t("jellyseerr.cancel")}
|
||||
/>
|
||||
<TVOptionSelector
|
||||
visible={activeSelector === "folder"}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
options={rootFolderOptions}
|
||||
onSelect={handleFolderChange}
|
||||
onClose={() => setActiveSelector(null)}
|
||||
cancelLabel={t("jellyseerr.cancel")}
|
||||
cardWidth={280}
|
||||
/>
|
||||
<TVOptionSelector
|
||||
visible={activeSelector === "user"}
|
||||
title={t("jellyseerr.request_as")}
|
||||
options={userOptions}
|
||||
onSelect={handleUserChange}
|
||||
onClose={() => setActiveSelector(null)}
|
||||
cancelLabel={t("jellyseerr.cancel")}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
paddingHorizontal: 44,
|
||||
overflow: "visible",
|
||||
},
|
||||
heading: {
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 24,
|
||||
},
|
||||
scrollView: {
|
||||
maxHeight: 320,
|
||||
overflow: "visible",
|
||||
},
|
||||
optionsContainer: {
|
||||
gap: 12,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
loadingContainer: {
|
||||
height: 200,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 24,
|
||||
},
|
||||
buttonText: {
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
446
app/(auth)/tv-season-select-modal.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { orderBy } from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVButton } from "@/components/tv";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||
import { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal";
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface TVSeasonToggleCardProps {
|
||||
season: {
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
episodeCount: number;
|
||||
status: MediaStatus;
|
||||
};
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
canRequest: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
||||
season,
|
||||
selected,
|
||||
onToggle,
|
||||
canRequest,
|
||||
hasTVPreferredFocus,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
// Get status icon and color based on MediaStatus
|
||||
const getStatusIcon = (): {
|
||||
icon: keyof typeof MaterialCommunityIcons.glyphMap;
|
||||
color: string;
|
||||
} | null => {
|
||||
switch (season.status) {
|
||||
case MediaStatus.PROCESSING:
|
||||
return { icon: "clock", color: "#6366f1" };
|
||||
case MediaStatus.AVAILABLE:
|
||||
return { icon: "check", color: "#22c55e" };
|
||||
case MediaStatus.PENDING:
|
||||
return { icon: "bell", color: "#eab308" };
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
return { icon: "minus", color: "#22c55e" };
|
||||
case MediaStatus.BLACKLISTED:
|
||||
return { icon: "eye-off", color: "#ef4444" };
|
||||
default:
|
||||
return canRequest ? { icon: "plus", color: "#22c55e" } : null;
|
||||
}
|
||||
};
|
||||
|
||||
const statusInfo = getStatusIcon();
|
||||
const isDisabled = !canRequest;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={canRequest ? onToggle : undefined}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isDisabled}
|
||||
focusable={!isDisabled}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
styles.seasonCard,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "#FFFFFF"
|
||||
: selected
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderWidth: focused ? 0 : 1,
|
||||
borderColor: selected
|
||||
? "rgba(255,255,255,0.4)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Checkmark for selected */}
|
||||
<View style={styles.checkmarkContainer}>
|
||||
{selected && (
|
||||
<Ionicons
|
||||
name='checkmark-circle'
|
||||
size={24}
|
||||
color={focused ? "#22c55e" : "#FFFFFF"}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Season info */}
|
||||
<View style={styles.seasonInfo}>
|
||||
<Text
|
||||
style={[
|
||||
styles.seasonTitle,
|
||||
{ color: focused ? "#000000" : "#FFFFFF" },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t("jellyseerr.season_number", {
|
||||
season_number: season.seasonNumber,
|
||||
})}
|
||||
</Text>
|
||||
<View style={styles.episodeRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.episodeCount,
|
||||
{
|
||||
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{t("jellyseerr.number_episodes", {
|
||||
episode_number: season.episodeCount,
|
||||
})}
|
||||
</Text>
|
||||
{statusInfo && (
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: statusInfo.color },
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={statusInfo.icon}
|
||||
size={14}
|
||||
color='#FFFFFF'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TVSeasonSelectModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
||||
const { t } = useTranslation();
|
||||
const { requestMedia } = useJellyseerr();
|
||||
const { showRequestModal } = useTVRequestModal();
|
||||
|
||||
// Selected seasons - initially select all requestable (UNKNOWN status) seasons
|
||||
const [selectedSeasons, setSelectedSeasons] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
// Initialize selected seasons when modal state changes
|
||||
useEffect(() => {
|
||||
if (modalState?.seasons) {
|
||||
const requestableSeasons = modalState.seasons
|
||||
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
|
||||
.map((s) => s.seasonNumber);
|
||||
setSelectedSeasons(new Set(requestableSeasons));
|
||||
}
|
||||
}, [modalState?.seasons]);
|
||||
|
||||
// Animate in on mount
|
||||
useEffect(() => {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(200);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
return () => {
|
||||
store.set(tvSeasonSelectModalAtom, null);
|
||||
};
|
||||
}, [overlayOpacity, sheetTranslateY]);
|
||||
|
||||
// Sort seasons by season number (ascending)
|
||||
const sortedSeasons = useMemo(() => {
|
||||
if (!modalState?.seasons) return [];
|
||||
return orderBy(
|
||||
modalState.seasons.filter((s) => s.seasonNumber !== 0),
|
||||
"seasonNumber",
|
||||
"asc",
|
||||
);
|
||||
}, [modalState?.seasons]);
|
||||
|
||||
// Find the index of the first requestable season for initial focus
|
||||
const firstRequestableIndex = useMemo(() => {
|
||||
return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN);
|
||||
}, [sortedSeasons]);
|
||||
|
||||
const handleToggleSeason = useCallback((seasonNumber: number) => {
|
||||
setSelectedSeasons((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(seasonNumber)) {
|
||||
newSet.delete(seasonNumber);
|
||||
} else {
|
||||
newSet.add(seasonNumber);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRequestSelected = useCallback(() => {
|
||||
if (!modalState || selectedSeasons.size === 0) return;
|
||||
|
||||
const seasonsArray = Array.from(selectedSeasons);
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: modalState.mediaId,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: modalState.tvdbId,
|
||||
seasons: seasonsArray,
|
||||
};
|
||||
|
||||
if (modalState.hasAdvancedRequestPermission) {
|
||||
// Close this modal and open the advanced request modal
|
||||
router.back();
|
||||
showRequestModal({
|
||||
requestBody: body,
|
||||
title: modalState.title,
|
||||
id: modalState.mediaId,
|
||||
mediaType: MediaType.TV,
|
||||
onRequested: modalState.onRequested,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the title based on selected seasons
|
||||
const seasonTitle =
|
||||
seasonsArray.length === 1
|
||||
? t("jellyseerr.season_number", { season_number: seasonsArray[0] })
|
||||
: seasonsArray.length === sortedSeasons.length
|
||||
? t("jellyseerr.season_all")
|
||||
: t("jellyseerr.n_selected", { count: seasonsArray.length });
|
||||
|
||||
requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => {
|
||||
modalState.onRequested();
|
||||
router.back();
|
||||
});
|
||||
}, [
|
||||
modalState,
|
||||
selectedSeasons,
|
||||
sortedSeasons.length,
|
||||
requestMedia,
|
||||
router,
|
||||
t,
|
||||
showRequestModal,
|
||||
]);
|
||||
|
||||
if (!modalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||
{t("jellyseerr.select_seasons")}
|
||||
</Text>
|
||||
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||
{modalState.title}
|
||||
</Text>
|
||||
|
||||
{/* Season cards horizontal scroll */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{sortedSeasons.map((season, index) => {
|
||||
const canRequestSeason = season.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<TVSeasonToggleCard
|
||||
key={season.id}
|
||||
season={season}
|
||||
selected={selectedSeasons.has(season.seasonNumber)}
|
||||
onToggle={() => handleToggleSeason(season.seasonNumber)}
|
||||
canRequest={canRequestSeason}
|
||||
hasTVPreferredFocus={index === firstRequestableIndex}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* Request button */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVButton
|
||||
onPress={handleRequestSelected}
|
||||
variant='secondary'
|
||||
disabled={selectedSeasons.size === 0}
|
||||
>
|
||||
<Ionicons
|
||||
name='add'
|
||||
size={22}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.buttonText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("jellyseerr.request_selected")}
|
||||
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
||||
</Text>
|
||||
</TVButton>
|
||||
</View>
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
paddingHorizontal: 44,
|
||||
overflow: "visible",
|
||||
},
|
||||
heading: {
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 24,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 4,
|
||||
gap: 16,
|
||||
},
|
||||
seasonCard: {
|
||||
width: 160,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
checkmarkContainer: {
|
||||
height: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
seasonInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
seasonTitle: {
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
episodeRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
episodeCount: {
|
||||
fontSize: 14,
|
||||
},
|
||||
statusBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 24,
|
||||
},
|
||||
buttonText: {
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
190
app/(auth)/tv-series-season-modal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVCancelButton, TVOptionCard } from "@/components/tv";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
export default function TVSeriesSeasonModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const firstCardRef = useRef<View>(null);
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
if (!modalState?.seasons) return 0;
|
||||
const idx = modalState.seasons.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [modalState?.seasons]);
|
||||
|
||||
// Animate in on mount
|
||||
useEffect(() => {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(200);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
store.set(tvSeriesSeasonModalAtom, null);
|
||||
};
|
||||
}, [overlayOpacity, sheetTranslateY]);
|
||||
|
||||
// Focus on the selected card when ready
|
||||
useEffect(() => {
|
||||
if (isReady && firstCardRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
const handleSelect = (seasonIndex: number) => {
|
||||
if (modalState?.onSeasonSelect) {
|
||||
modalState.onSeasonSelect(seasonIndex);
|
||||
}
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
if (!modalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
<Text style={[styles.title, { fontSize: typography.callout }]}>
|
||||
{t("item_card.select_season")}
|
||||
</Text>
|
||||
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{modalState.seasons.map((season, index) => (
|
||||
<TVOptionCard
|
||||
key={season.value}
|
||||
ref={
|
||||
index === initialSelectedIndex ? firstCardRef : undefined
|
||||
}
|
||||
label={season.label}
|
||||
selected={season.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => handleSelect(season.value)}
|
||||
width={180}
|
||||
height={85}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{isReady && (
|
||||
<View style={styles.cancelButtonContainer}>
|
||||
<TVCancelButton
|
||||
onPress={handleCancel}
|
||||
label={t("common.cancel")}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
},
|
||||
title: {
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
1300
app/(auth)/tv-subtitle-modal.tsx
Normal file
174
app/(auth)/tv-user-switch-modal.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, 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 { TVUserCard } from "@/components/tv/TVUserCard";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
|
||||
import type { SavedServerAccount } from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
export default function TVUserSwitchModalPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvUserSwitchModalAtom);
|
||||
|
||||
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;
|
||||
|
||||
// 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(tvUserSwitchModalAtom, 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 = (account: SavedServerAccount) => {
|
||||
modalState?.onAccountSelect(account);
|
||||
store.set(tvUserSwitchModalAtom, null);
|
||||
router.back();
|
||||
};
|
||||
|
||||
// If no modal state, just return null
|
||||
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("home.settings.switch_user.title")}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>{modalState.serverName}</Text>
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{modalState.accounts.map((account, index) => {
|
||||
const isCurrent = account.userId === modalState.currentUserId;
|
||||
return (
|
||||
<TVUserCard
|
||||
key={account.userId}
|
||||
ref={index === 0 ? firstCardRef : undefined}
|
||||
username={account.username}
|
||||
securityType={account.securityType}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
isCurrent={isCurrent}
|
||||
onPress={() => handleSelect(account)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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: 4,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 20,
|
||||
gap: 16,
|
||||
},
|
||||
});
|
||||
242
app/_layout.tsx
@@ -10,9 +10,11 @@ import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||
import { InactivityProvider } from "@/providers/InactivityProvider";
|
||||
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
@@ -54,15 +56,31 @@ import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance } from "react-native";
|
||||
import { Appearance, LogBox } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
|
||||
// Suppress harmless tvOS warning from react-native-gesture-handler
|
||||
if (Platform.isTV) {
|
||||
LogBox.ignoreLogs(["HoverGestureHandler is not supported on tvOS"]);
|
||||
}
|
||||
|
||||
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 {
|
||||
configureReanimatedLogger,
|
||||
ReanimatedLogLevel,
|
||||
} from "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
// Disable strict mode warnings for reading shared values during render
|
||||
configureReanimatedLogger({
|
||||
level: ReanimatedLogLevel.warn,
|
||||
strict: false,
|
||||
});
|
||||
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
@@ -178,7 +196,7 @@ export default function RootLayout() {
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<JotaiProvider>
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<ActionSheetProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Layout />
|
||||
@@ -232,6 +250,11 @@ function Layout() {
|
||||
const _segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
// Enable TV menu key interception so React Native handles it instead of tvOS
|
||||
useEffect(() => {
|
||||
enableTVMenuKeyInterception();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(
|
||||
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
|
||||
@@ -252,22 +275,19 @@ function Layout() {
|
||||
deviceId: getOrSetDeviceId(),
|
||||
userId: user.Id,
|
||||
})
|
||||
.then((_) => console.log("Posted expo push token"))
|
||||
.catch((_) =>
|
||||
writeErrorLog("Failed to push expo push token to plugin"),
|
||||
);
|
||||
} else console.log("No token available");
|
||||
}
|
||||
}, [api, expoPushToken, user]);
|
||||
|
||||
const registerNotifications = useCallback(async () => {
|
||||
if (Platform.OS === "android") {
|
||||
console.log("Setting android notification channel 'default'");
|
||||
await Notifications?.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
});
|
||||
|
||||
// Create dedicated channel for download notifications
|
||||
console.log("Setting android notification channel 'downloads'");
|
||||
await Notifications?.setNotificationChannelAsync("downloads", {
|
||||
name: "Downloads",
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
@@ -382,79 +402,145 @@ function Layout() {
|
||||
}}
|
||||
>
|
||||
<JellyfinProvider>
|
||||
<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,
|
||||
<InactivityProvider>
|
||||
<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.Screen
|
||||
name='tv-account-action-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='tv-account-select-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-user-switch-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>
|
||||
</ServerUrlProvider>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
</InactivityProvider>
|
||||
</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;
|
||||
|
||||
251
app/tv-account-action-modal.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
TVFocusGuideView,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
// Action card component
|
||||
const TVAccountActionCard: React.FC<{
|
||||
label: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
variant?: "default" | "destructive";
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
const isDestructive = variant === "destructive";
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
height: 60,
|
||||
backgroundColor: focused
|
||||
? isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff"
|
||||
: isDestructive
|
||||
? "rgba(239, 68, 68, 0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 24,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={22}
|
||||
color={
|
||||
focused
|
||||
? isDestructive
|
||||
? "#fff"
|
||||
: "#000"
|
||||
: isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff"
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: focused
|
||||
? isDestructive
|
||||
? "#fff"
|
||||
: "#000"
|
||||
: isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TVAccountActionModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvAccountActionModalAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
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(tvAccountActionModalAtom, null);
|
||||
};
|
||||
}, [overlayOpacity, sheetTranslateY]);
|
||||
|
||||
const handleLogin = () => {
|
||||
modalState?.onLogin();
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
modalState?.onDelete();
|
||||
router.back();
|
||||
};
|
||||
|
||||
if (!modalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
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,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Account username as title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 4,
|
||||
paddingHorizontal: 48,
|
||||
}}
|
||||
>
|
||||
{modalState.account.username}
|
||||
</Text>
|
||||
|
||||
{/* Server name as subtitle */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
}}
|
||||
>
|
||||
{modalState.server.name || modalState.server.address}
|
||||
</Text>
|
||||
|
||||
{/* Horizontal options */}
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<TVAccountActionCard
|
||||
label={t("common.login")}
|
||||
icon='log-in-outline'
|
||||
hasTVPreferredFocus
|
||||
onPress={handleLogin}
|
||||
/>
|
||||
<TVAccountActionCard
|
||||
label={t("common.delete")}
|
||||
icon='trash-outline'
|
||||
variant='destructive'
|
||||
onPress={handleDelete}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
256
app/tv-account-select-modal.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
TVFocusGuideView,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVUserCard } from "@/components/tv/TVUserCard";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
// Action button for bottom sheet
|
||||
const TVAccountSelectAction: React.FC<{
|
||||
label: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
variant?: "default" | "destructive";
|
||||
onPress: () => void;
|
||||
}> = ({ label, icon, variant = "default", onPress }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
const isDestructive = variant === "destructive";
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
backgroundColor: focused
|
||||
? isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff"
|
||||
: isDestructive
|
||||
? "rgba(239, 68, 68, 0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 14,
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
minHeight: 72,
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={22}
|
||||
color={
|
||||
focused
|
||||
? isDestructive
|
||||
? "#fff"
|
||||
: "#000"
|
||||
: isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff"
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: focused
|
||||
? isDestructive
|
||||
? "#fff"
|
||||
: "#000"
|
||||
: isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TVAccountSelectModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvAccountSelectModalAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
// Animate in on mount
|
||||
useEffect(() => {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(300);
|
||||
|
||||
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(tvAccountSelectModalAtom, null);
|
||||
};
|
||||
}, [overlayOpacity, sheetTranslateY]);
|
||||
|
||||
if (!modalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
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,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 4,
|
||||
paddingHorizontal: 48,
|
||||
}}
|
||||
>
|
||||
{t("server.select_account")}
|
||||
</Text>
|
||||
|
||||
{/* Server name as subtitle */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
}}
|
||||
>
|
||||
{modalState.server.name || modalState.server.address}
|
||||
</Text>
|
||||
|
||||
{/* All options in single horizontal row */}
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 20,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{modalState.server.accounts?.map((account, index) => (
|
||||
<TVUserCard
|
||||
key={account.userId}
|
||||
username={account.username}
|
||||
securityType={account.securityType}
|
||||
onPress={() => {
|
||||
modalState.onAccountAction(account);
|
||||
}}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
/>
|
||||
))}
|
||||
<TVAccountSelectAction
|
||||
label={t("server.add_account")}
|
||||
icon='person-add-outline'
|
||||
onPress={() => {
|
||||
modalState.onAddAccount();
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
<TVAccountSelectAction
|
||||
label={t("server.remove_server")}
|
||||
icon='trash-outline'
|
||||
variant='destructive'
|
||||
onPress={() => {
|
||||
modalState.onDeleteServer();
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
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 |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
@@ -1,3 +1,4 @@
|
||||
export * from "./api";
|
||||
export * from "./mmkv";
|
||||
export * from "./number";
|
||||
export * from "./string";
|
||||
|
||||
@@ -3,6 +3,7 @@ declare global {
|
||||
bytesToReadable(decimals?: number): string;
|
||||
secondsToMilliseconds(): number;
|
||||
minutesToMilliseconds(): number;
|
||||
hoursToMilliseconds(): number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
|
||||
return this.valueOf() * (60).secondsToMilliseconds();
|
||||
};
|
||||
|
||||
Number.prototype.hoursToMilliseconds = function () {
|
||||
return this.valueOf() * (60).minutesToMilliseconds();
|
||||
};
|
||||
|
||||
export {};
|
||||
|
||||
14
augmentations/string.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
declare global {
|
||||
interface String {
|
||||
toTitle(): string;
|
||||
}
|
||||
}
|
||||
|
||||
String.prototype.toTitle = function () {
|
||||
return this.replaceAll("_", " ").replace(
|
||||
/\w\S*/g,
|
||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||
);
|
||||
};
|
||||
|
||||
export {};
|
||||
11
bun.lock
@@ -25,6 +25,7 @@
|
||||
"expo": "~54.0.31",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-asset": "~12.0.12",
|
||||
"expo-av": "^16.0.8",
|
||||
"expo-background-task": "~1.0.10",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-brightness": "~14.0.8",
|
||||
@@ -58,7 +59,7 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "16.5.3",
|
||||
"react-native": "0.81.5",
|
||||
"react-native": "npm:react-native-tvos@0.81.5-2",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "1.1.0",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
@@ -540,6 +541,8 @@
|
||||
|
||||
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
|
||||
|
||||
"@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-2", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-i5L6sJ8Dae5JUWhfb5w/RgZUm3CYRFhV5/PB/xu3ASxFyHjfO0kQAqcU3ySNAOR0HfmaXK8R4OC0h07zoUWKrQ=="],
|
||||
|
||||
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
|
||||
|
||||
"@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="],
|
||||
@@ -560,8 +563,6 @@
|
||||
|
||||
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
|
||||
|
||||
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.81.5", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw=="],
|
||||
|
||||
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
|
||||
|
||||
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
|
||||
@@ -1008,6 +1009,8 @@
|
||||
|
||||
"expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="],
|
||||
|
||||
"expo-av": ["expo-av@16.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ=="],
|
||||
|
||||
"expo-background-task": ["expo-background-task@1.0.10", "", { "dependencies": { "expo-task-manager": "~14.0.9" }, "peerDependencies": { "expo": "*" } }, "sha512-EbPnuf52Ps/RJiaSFwqKGT6TkvMChv7bI0wF42eADbH3J2EMm5y5Qvj0oFmF1CBOwc3mUhqj63o7Pl6OLkGPZQ=="],
|
||||
|
||||
"expo-blur": ["expo-blur@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w=="],
|
||||
@@ -1644,7 +1647,7 @@
|
||||
|
||||
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||
|
||||
"react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="],
|
||||
"react-native": ["react-native-tvos@0.81.5-2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-2", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-y/V8iFZGNXQq6b+X9VBQG19PaBpAXQHhv2vhcCMe2gEePqI2Uu8n3ClqglBn8u+Fl/GXCMcFdnJ0v0nRyxJ5TA=="],
|
||||
|
||||
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -14,6 +16,8 @@ export const Badge: React.FC<Props> = ({
|
||||
variant = "purple",
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
const content = (
|
||||
<View style={styles.content}>
|
||||
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||
@@ -28,7 +32,7 @@ export const Badge: React.FC<Props> = ({
|
||||
</View>
|
||||
);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<View {...props} style={[styles.container, props.style]}>
|
||||
<GlassEffectView style={{ borderRadius: 100 }}>
|
||||
@@ -38,21 +42,70 @@ export const Badge: React.FC<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// On TV, use BlurView for consistent styling
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<BlurView
|
||||
intensity={10}
|
||||
tint='light'
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
alignSelf: "flex-start",
|
||||
flexShrink: 1,
|
||||
flexGrow: 0,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
},
|
||||
props.style,
|
||||
]}
|
||||
>
|
||||
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#E5E7EB",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</BlurView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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"}
|
||||
`}
|
||||
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 className='mr-1'>{iconLeft}</View>}
|
||||
{iconLeft && <View style={{ marginRight: 4 }}>{iconLeft}</View>}
|
||||
<Text
|
||||
className={`
|
||||
text-xs
|
||||
${variant === "purple" && "text-white"}
|
||||
`}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
|
||||
@@ -122,7 +122,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.08);
|
||||
animateTo(1.03);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
@@ -132,10 +132,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
shadowColor: "#a855f7",
|
||||
shadowColor: "#ffffff",
|
||||
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
|
||||
}}
|
||||
>
|
||||
|
||||
0
components/ContextMenu.tv.ts
Normal file
@@ -73,12 +73,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
const playSettingsOptions = useMemo(
|
||||
() => ({ applyLanguagePreferences: true }),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(items[0], settings);
|
||||
} = useDefaultPlaySettings(items[0], settings, playSettingsOptions);
|
||||
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
|
||||
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Example Usage of Global Modal
|
||||
*
|
||||
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||
* You can delete this file after understanding how it works.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
* Example 1: Simple Content Modal
|
||||
*/
|
||||
export const SimpleModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This is a simple modal with just some text content.
|
||||
</Text>
|
||||
<Text className='text-neutral-400'>
|
||||
Swipe down or tap outside to close.
|
||||
</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 2: Modal with Custom Snap Points
|
||||
*/
|
||||
export const CustomSnapPointsExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6' style={{ minHeight: 400 }}>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Custom Snap Points
|
||||
</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This modal has custom snap points (25%, 50%, 90%).
|
||||
</Text>
|
||||
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||
<Text className='text-white'>
|
||||
Try dragging the modal to different heights!
|
||||
</Text>
|
||||
</View>
|
||||
</View>,
|
||||
{
|
||||
snapPoints: ["25%", "50%", "90%"],
|
||||
enableDynamicSizing: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 3: Complex Component in Modal
|
||||
*/
|
||||
const SettingsModalContent = () => {
|
||||
const { hideModal } = useGlobalModal();
|
||||
|
||||
const settings = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Notifications",
|
||||
icon: "notifications-outline" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||
{
|
||||
id: 3,
|
||||
title: "Auto-play",
|
||||
icon: "play-outline" as const,
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||
|
||||
{settings.map((setting, index) => (
|
||||
<View
|
||||
key={setting.id}
|
||||
className={`flex-row items-center justify-between py-4 ${
|
||||
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||
}`}
|
||||
>
|
||||
<View className='flex-row items-center gap-3'>
|
||||
<Ionicons name={setting.icon} size={24} color='white' />
|
||||
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${
|
||||
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={hideModal}
|
||||
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||
>
|
||||
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplexModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(<SettingsModalContent />);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||
*/
|
||||
export const useShowSuccessModal = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
return (message: string) => {
|
||||
showModal(
|
||||
<View className='p-6 items-center'>
|
||||
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||
<Ionicons name='checkmark' size={48} color='white' />
|
||||
</View>
|
||||
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||
<Text className='text-white text-center'>{message}</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Main Demo Component
|
||||
*/
|
||||
export const GlobalModalDemo = () => {
|
||||
const showSuccess = useShowSuccessModal();
|
||||
|
||||
return (
|
||||
<View className='p-6 gap-4'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Global Modal Examples
|
||||
</Text>
|
||||
|
||||
<SimpleModalExample />
|
||||
<CustomSnapPointsExample />
|
||||
<ComplexModalExample />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => showSuccess("Operation completed successfully!")}
|
||||
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
// GenreTags.tsx
|
||||
import { BlurView } from "expo-blur";
|
||||
import type React from "react";
|
||||
import {
|
||||
Platform,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface TagProps {
|
||||
@@ -23,7 +25,10 @@ export const Tag: React.FC<
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
} & ViewProps
|
||||
> = ({ text, textClass, textStyle, ...props }) => {
|
||||
if (Platform.OS === "ios") {
|
||||
// Hook must be called at the top level, before any conditional returns
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<View>
|
||||
<GlassEffectView style={styles.glass}>
|
||||
@@ -40,6 +45,32 @@ export const Tag: React.FC<
|
||||
);
|
||||
}
|
||||
|
||||
// TV-specific styling with blur background
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<BlurView
|
||||
intensity={10}
|
||||
tint='light'
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: typography.callout, color: "#E5E7EB" }}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</BlurView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
||||
<Text className={textClass} style={textStyle}>
|
||||
@@ -66,7 +97,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) => (
|
||||
|
||||
@@ -89,16 +89,16 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
</Text>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<Image
|
||||
source={require("@/assets/icons/seerr-logo.svg")}
|
||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
/>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Seerr</Text>
|
||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.seerr_feature_description")}
|
||||
{t("home.intro.jellyseerr_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -158,12 +158,12 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.intro.centralized_settings_plugin_title")}
|
||||
{t("home.intro.centralised_settings_plugin_title")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap items-baseline'>
|
||||
<Text className='shrink text-xs'>
|
||||
{t(
|
||||
"home.intro.centralized_settings_plugin_description",
|
||||
"home.intro.centralised_settings_plugin_description",
|
||||
)}{" "}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -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";
|
||||
@@ -36,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;
|
||||
@@ -45,229 +47,251 @@ export type SelectedOptions = {
|
||||
};
|
||||
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
item?: BaseItemDto | null;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ 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);
|
||||
// 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 playSettingsOptions = useMemo(
|
||||
() => ({ applyLanguagePreferences: true }),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(
|
||||
itemWithSources ?? item,
|
||||
settings,
|
||||
playSettingsOptions,
|
||||
);
|
||||
|
||||
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}
|
||||
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} />
|
||||
</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} />
|
||||
// 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} />;
|
||||
};
|
||||
|
||||
965
components/ItemContent.tv.tsx
Normal file
@@ -0,0 +1,965 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { File } from "expo-file-system";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Dimensions, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
|
||||
import {
|
||||
TVBackdrop,
|
||||
TVButton,
|
||||
TVCastCrewText,
|
||||
TVCastSection,
|
||||
TVFavoriteButton,
|
||||
TVMetadataBadges,
|
||||
TVOptionButton,
|
||||
TVPlayedButton,
|
||||
TVProgressBar,
|
||||
TVRefreshButton,
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
} from "@/components/tv";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
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 { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
audioIndex: number | undefined;
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
interface ItemContentTVProps {
|
||||
item?: BaseItemDto | null;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
|
||||
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
({ item, itemWithSources }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { showItemActions } = useTVItemActionModal();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const _itemColors = useImageColorsReturn({ item });
|
||||
|
||||
// Auto-play theme music (handles fade in/out and cleanup)
|
||||
useTVThemeMusic(item?.Id);
|
||||
|
||||
// State for first episode card ref (used for focus guide)
|
||||
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
|
||||
|
||||
// Fetch season episodes for episodes
|
||||
const { data: seasonEpisodes = [] } = useQuery({
|
||||
queryKey: ["episodes", item?.SeasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return [];
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.SeriesId,
|
||||
userId: user.Id,
|
||||
seasonId: item.SeasonId,
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "Overview"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
},
|
||||
enabled:
|
||||
!!api &&
|
||||
!!user?.Id &&
|
||||
!!item?.SeriesId &&
|
||||
!!item?.SeasonId &&
|
||||
item?.Type === "Episode",
|
||||
});
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
// Enable language preference application for TV
|
||||
const playSettingsOptions = useMemo(
|
||||
() => ({ applyLanguagePreferences: true }),
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(
|
||||
itemWithSources ?? item,
|
||||
settings,
|
||||
playSettingsOptions,
|
||||
);
|
||||
|
||||
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 navigateToPlayer = useCallback(
|
||||
(playbackPosition: string) => {
|
||||
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,
|
||||
offline: isOffline ? "true" : "false",
|
||||
});
|
||||
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
},
|
||||
[item, selectedOptions, isOffline, router],
|
||||
);
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!item || !selectedOptions) return;
|
||||
|
||||
const hasPlaybackProgress =
|
||||
(item.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||
|
||||
if (hasPlaybackProgress) {
|
||||
Alert.alert(
|
||||
t("item_card.resume_playback"),
|
||||
t("item_card.resume_playback_description"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("item_card.play_from_start"),
|
||||
onPress: () => navigateToPlayer("0"),
|
||||
},
|
||||
{
|
||||
text: t("item_card.continue_from", {
|
||||
time: formatDuration(item.UserData?.PlaybackPositionTicks),
|
||||
}),
|
||||
onPress: () =>
|
||||
navigateToPlayer(
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
),
|
||||
isPreferred: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
} else {
|
||||
navigateToPlayer("0");
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
// State to trigger refresh of local subtitles list
|
||||
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
||||
|
||||
// Starting index for local (client-downloaded) subtitles
|
||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||
|
||||
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||
const tracks: Track[] = subtitleStreams.map((stream) => ({
|
||||
name:
|
||||
stream.DisplayTitle ||
|
||||
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||
index: stream.Index ?? -1,
|
||||
setTrack: () => {
|
||||
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
||||
},
|
||||
}));
|
||||
|
||||
// Add locally downloaded subtitles (from OpenSubtitles)
|
||||
if (item?.Id) {
|
||||
const localSubs = getSubtitlesForItem(item.Id);
|
||||
let localIdx = 0;
|
||||
for (const localSub of localSubs) {
|
||||
// Verify file still exists (cache may have been cleared)
|
||||
const subtitleFile = new File(localSub.filePath);
|
||||
if (!subtitleFile.exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
|
||||
tracks.push({
|
||||
name: localSub.name,
|
||||
index: localIndex,
|
||||
isLocal: true,
|
||||
localPath: localSub.filePath,
|
||||
setTrack: () => {
|
||||
// For ItemContent (outside player), just update the selected index
|
||||
// The actual subtitle will be loaded when playback starts
|
||||
handleSubtitleChangeRef.current?.(localIndex);
|
||||
},
|
||||
});
|
||||
localIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
return tracks;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Handle local subtitle download - trigger refresh of subtitle tracks
|
||||
const handleLocalSubtitleDownloaded = useCallback((_path: string) => {
|
||||
// Increment the refresh key to trigger re-computation of subtitleTracksForModal
|
||||
setLocalSubtitlesRefreshKey((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
const tracks: Track[] = streams.map((stream) => ({
|
||||
name:
|
||||
stream.DisplayTitle ||
|
||||
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||
index: stream.Index ?? -1,
|
||||
setTrack: () => {
|
||||
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
||||
},
|
||||
}));
|
||||
|
||||
// Add locally downloaded subtitles
|
||||
if (item?.Id) {
|
||||
const localSubs = getSubtitlesForItem(item.Id);
|
||||
let localIdx = 0;
|
||||
for (const localSub of localSubs) {
|
||||
const subtitleFile = new File(localSub.filePath);
|
||||
if (!subtitleFile.exists) continue;
|
||||
|
||||
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
|
||||
tracks.push({
|
||||
name: localSub.name,
|
||||
index: localIndex,
|
||||
isLocal: true,
|
||||
localPath: localSub.filePath,
|
||||
setTrack: () => {
|
||||
handleSubtitleChangeRef.current?.(localIndex);
|
||||
},
|
||||
});
|
||||
localIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
return tracks;
|
||||
} 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");
|
||||
|
||||
// Check if it's a local subtitle (negative index starting at -100)
|
||||
if (
|
||||
selectedOptions?.subtitleIndex !== undefined &&
|
||||
selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START
|
||||
) {
|
||||
const localTrack = subtitleTracksForModal.find(
|
||||
(t) => t.index === selectedOptions.subtitleIndex,
|
||||
);
|
||||
return localTrack?.name || t("item_card.subtitles.label");
|
||||
}
|
||||
|
||||
const track = subtitleStreams.find(
|
||||
(t) => t.Index === selectedOptions?.subtitleIndex,
|
||||
);
|
||||
return (
|
||||
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
||||
);
|
||||
}, [
|
||||
subtitleStreams,
|
||||
subtitleTracksForModal,
|
||||
selectedOptions?.subtitleIndex,
|
||||
t,
|
||||
]);
|
||||
|
||||
const selectedMediaSourceLabel = useMemo(() => {
|
||||
const source = selectedOptions?.mediaSource;
|
||||
if (!source) return t("item_card.video");
|
||||
const videoStream = source.MediaStreams?.find((s) => s.Type === "Video");
|
||||
return videoStream?.DisplayTitle || source.Name || t("item_card.video");
|
||||
}, [selectedOptions?.mediaSource, t]);
|
||||
|
||||
const selectedQualityLabel = useMemo(() => {
|
||||
return selectedOptions?.bitrate?.key || t("item_card.quality");
|
||||
}, [selectedOptions?.bitrate?.key, t]);
|
||||
|
||||
// Format year and duration
|
||||
const year = item?.ProductionYear;
|
||||
const duration = item?.RunTimeTicks
|
||||
? runtimeTicksToMinutes(item.RunTimeTicks)
|
||||
: null;
|
||||
const hasProgress = (item?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||
const remainingTime = hasProgress
|
||||
? runtimeTicksToMinutes(
|
||||
(item?.RunTimeTicks || 0) -
|
||||
(item?.UserData?.PlaybackPositionTicks || 0),
|
||||
)
|
||||
: null;
|
||||
|
||||
// Get director
|
||||
const director = item?.People?.find((p) => p.Type === "Director");
|
||||
|
||||
// Get cast (first 3 for text display)
|
||||
const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3);
|
||||
|
||||
// Get full cast for visual display (up to 10 actors)
|
||||
const fullCast = useMemo(() => {
|
||||
return (
|
||||
item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? []
|
||||
);
|
||||
}, [item?.People]);
|
||||
|
||||
// Whether to show visual cast section
|
||||
const showVisualCast =
|
||||
(item?.Type === "Movie" ||
|
||||
item?.Type === "Series" ||
|
||||
item?.Type === "Episode") &&
|
||||
fullCast.length > 0;
|
||||
|
||||
// Series/Season image URLs for episodes
|
||||
const seriesImageUrl = useMemo(() => {
|
||||
if (item?.Type !== "Episode" || !item.SeriesId) return null;
|
||||
return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 });
|
||||
}, [api, item?.Type, item?.SeriesId]);
|
||||
|
||||
const seasonImageUrl = useMemo(() => {
|
||||
if (item?.Type !== "Episode") return null;
|
||||
const seasonId = item.SeasonId || item.ParentId;
|
||||
if (!seasonId) return null;
|
||||
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
|
||||
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
||||
|
||||
// Episode thumbnail URL - episode's own primary image (16:9 for episodes)
|
||||
const episodeThumbnailUrl = useMemo(() => {
|
||||
if (item?.Type !== "Episode" || !api) return null;
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}, [api, item]);
|
||||
|
||||
// Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled
|
||||
const seriesThumbUrl = useMemo(() => {
|
||||
if (item?.Type !== "Episode" || !item.SeriesId || !api) return null;
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
|
||||
}, [api, item]);
|
||||
|
||||
// Navigation handlers
|
||||
const handleActorPress = useCallback(
|
||||
(personId: string) => {
|
||||
router.push(`/(auth)/persons/${personId}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleSeriesPress = useCallback(() => {
|
||||
if (item?.SeriesId) {
|
||||
router.push(`/(auth)/series/${item.SeriesId}`);
|
||||
}
|
||||
}, [router, item?.SeriesId]);
|
||||
|
||||
const handleSeasonPress = useCallback(() => {
|
||||
if (item?.SeriesId && item?.ParentIndexNumber) {
|
||||
router.push(
|
||||
`/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`,
|
||||
);
|
||||
}
|
||||
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
|
||||
|
||||
const handleEpisodePress = useCallback(
|
||||
(episode: BaseItemDto) => {
|
||||
const navigation = getItemNavigation(episode, "(home)");
|
||||
router.replace(navigation as any);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
if (!item || !selectedOptions) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#000000",
|
||||
}}
|
||||
>
|
||||
{/* Full-screen backdrop */}
|
||||
<TVBackdrop item={item} />
|
||||
|
||||
{/* Main content area */}
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 140,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingHorizontal: insets.left + 80,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Top section - Logo/Title + Metadata */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
minHeight: SCREEN_HEIGHT * 0.45,
|
||||
}}
|
||||
>
|
||||
{/* Left side - Content */}
|
||||
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||
{/* Logo or Title */}
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{ uri: logoUrl }}
|
||||
style={{
|
||||
height: 150,
|
||||
width: "80%",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
contentFit='contain'
|
||||
contentPosition='left'
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.display,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Episode info for TV shows */}
|
||||
{item.Type === "Episode" && (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.title,
|
||||
color: "#FFFFFF",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "white",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
S{item.ParentIndexNumber} E{item.IndexNumber} · {item.Name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Metadata badges row */}
|
||||
<TVMetadataBadges
|
||||
year={year}
|
||||
duration={duration}
|
||||
officialRating={item.OfficialRating}
|
||||
communityRating={item.CommunityRating}
|
||||
/>
|
||||
|
||||
{/* Genres */}
|
||||
{item.Genres && item.Genres.length > 0 && (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<GenreTags genres={item.Genres} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
{item.Overview && (
|
||||
<BlurView
|
||||
intensity={10}
|
||||
tint='light'
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
maxWidth: SCREEN_WIDTH * 0.45,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
padding: 16,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "#E5E7EB",
|
||||
lineHeight: 32,
|
||||
}}
|
||||
numberOfLines={4}
|
||||
>
|
||||
{item.Overview}
|
||||
</Text>
|
||||
</View>
|
||||
</BlurView>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<TVButton
|
||||
onPress={handlePlay}
|
||||
hasTVPreferredFocus
|
||||
variant='primary'
|
||||
>
|
||||
<Ionicons
|
||||
name='play'
|
||||
size={28}
|
||||
color='#000000'
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
}}
|
||||
>
|
||||
{hasProgress
|
||||
? `${remainingTime} ${t("item_card.left")}`
|
||||
: t("common.play")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
<TVFavoriteButton item={item} />
|
||||
<TVPlayedButton item={item} />
|
||||
<TVRefreshButton itemId={item.Id} />
|
||||
</View>
|
||||
|
||||
{/* Playback options */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{/* Quality selector */}
|
||||
<TVOptionButton
|
||||
label={t("item_card.quality")}
|
||||
value={selectedQualityLabel}
|
||||
maxWidth={200}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("item_card.quality"),
|
||||
options: qualityOptions,
|
||||
onSelect: handleQualityChange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Media source selector (only if multiple sources) */}
|
||||
{mediaSources.length > 1 && (
|
||||
<TVOptionButton
|
||||
label={t("item_card.video")}
|
||||
value={selectedMediaSourceLabel}
|
||||
maxWidth={280}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("item_card.video"),
|
||||
options: mediaSourceOptions,
|
||||
onSelect: handleMediaSourceChange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Audio selector */}
|
||||
{audioTracks.length > 0 && (
|
||||
<TVOptionButton
|
||||
label={t("item_card.audio")}
|
||||
value={selectedAudioLabel}
|
||||
maxWidth={280}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("item_card.audio"),
|
||||
options: audioOptions,
|
||||
onSelect: handleAudioChange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Subtitle selector */}
|
||||
{(subtitleStreams.length > 0 ||
|
||||
selectedOptions?.subtitleIndex !== undefined) && (
|
||||
<TVOptionButton
|
||||
label={t("item_card.subtitles.label")}
|
||||
value={selectedSubtitleLabel}
|
||||
maxWidth={280}
|
||||
onPress={() =>
|
||||
showSubtitleModal({
|
||||
item,
|
||||
mediaSourceId: selectedOptions?.mediaSource?.Id,
|
||||
subtitleTracks: subtitleTracksForModal,
|
||||
currentSubtitleIndex:
|
||||
selectedOptions?.subtitleIndex ?? -1,
|
||||
onDisableSubtitles: () => handleSubtitleChange(-1),
|
||||
onServerSubtitleDownloaded:
|
||||
handleServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded:
|
||||
handleLocalSubtitleDownloaded,
|
||||
refreshSubtitleTracks,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Progress bar (if partially watched) */}
|
||||
{hasProgress && item.RunTimeTicks != null && (
|
||||
<TVProgressBar
|
||||
progress={
|
||||
(item.UserData?.PlaybackPositionTicks || 0) /
|
||||
item.RunTimeTicks
|
||||
}
|
||||
fillColor='#FFFFFF'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right side - Poster */}
|
||||
<View
|
||||
style={{
|
||||
width:
|
||||
item.Type === "Episode"
|
||||
? SCREEN_WIDTH * 0.35
|
||||
: SCREEN_WIDTH * 0.22,
|
||||
marginLeft: 50,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: item.Type === "Episode" ? 16 / 9 : 2 / 3,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 20,
|
||||
}}
|
||||
>
|
||||
{item.Type === "Episode" ? (
|
||||
<Image
|
||||
source={{
|
||||
uri:
|
||||
settings.showSeriesPosterOnEpisode && seriesThumbUrl
|
||||
? seriesThumbUrl
|
||||
: episodeThumbnailUrl!,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<ItemImage
|
||||
variant='Primary'
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Additional info section */}
|
||||
<View style={{ marginTop: 40 }}>
|
||||
{/* Season Episodes - Episode only */}
|
||||
{item.Type === "Episode" && seasonEpisodes.length > 1 && (
|
||||
<View style={{ marginBottom: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{t("item_card.more_from_this_season")}
|
||||
</Text>
|
||||
|
||||
<TVEpisodeList
|
||||
episodes={seasonEpisodes}
|
||||
currentEpisodeId={item.Id}
|
||||
onEpisodePress={handleEpisodePress}
|
||||
onEpisodeLongPress={showItemActions}
|
||||
firstEpisodeRefSetter={setFirstEpisodeRef}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* From this Series - Episode only */}
|
||||
<TVSeriesNavigation
|
||||
item={item}
|
||||
seriesImageUrl={seriesImageUrl}
|
||||
seasonImageUrl={seasonImageUrl}
|
||||
onSeriesPress={handleSeriesPress}
|
||||
onSeasonPress={handleSeasonPress}
|
||||
/>
|
||||
|
||||
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
||||
{showVisualCast && (
|
||||
<TVCastSection
|
||||
cast={fullCast}
|
||||
apiBasePath={api?.basePath}
|
||||
onActorPress={handleActorPress}
|
||||
firstActorRefSetter={setFirstActorCardRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cast & Crew (text version - director, etc.) */}
|
||||
<TVCastCrewText
|
||||
director={director}
|
||||
cast={cast}
|
||||
hideCast={showVisualCast}
|
||||
/>
|
||||
|
||||
{/* Technical details */}
|
||||
{selectedOptions.mediaSource?.MediaStreams &&
|
||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||
<TVTechnicalDetails
|
||||
mediaStreams={selectedOptions.mediaSource.MediaStreams}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files)
|
||||
export const ItemContent = ItemContentTV;
|
||||
163
components/ItemContentSkeleton.tv.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from "react";
|
||||
import { Dimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
|
||||
export const ItemContentSkeletonTV: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
paddingTop: insets.top + 140,
|
||||
paddingHorizontal: insets.left + 80,
|
||||
}}
|
||||
>
|
||||
{/* Left side - Content placeholders */}
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Logo placeholder */}
|
||||
<View
|
||||
style={{
|
||||
height: 150,
|
||||
width: "80%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 8,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Metadata badges row */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: 24,
|
||||
width: 60,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 24,
|
||||
width: 80,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 24,
|
||||
width: 50,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Genres placeholder */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: 28,
|
||||
width: 80,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 14,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 28,
|
||||
width: 100,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 14,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 28,
|
||||
width: 70,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 14,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Overview placeholder */}
|
||||
<View
|
||||
style={{
|
||||
maxWidth: SCREEN_WIDTH * 0.45,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: 18,
|
||||
width: "100%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 18,
|
||||
width: "90%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 18,
|
||||
width: "75%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Play button placeholder */}
|
||||
<View
|
||||
style={{
|
||||
height: 56,
|
||||
width: 180,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Right side - Poster placeholder */}
|
||||
<View
|
||||
style={{
|
||||
width: SCREEN_WIDTH * 0.22,
|
||||
marginLeft: 50,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -77,7 +77,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
|
||||
<View>
|
||||
<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],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||
|
||||
interface Props extends ViewProps {
|
||||
actorId: string;
|
||||
actorName?: string | null;
|
||||
|
||||
@@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
||||
{/* Password Input */}
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
||||
<Text className='text-neutral-400 text-sm mb-2'>
|
||||
{t("login.password")}
|
||||
{t("login.password_placeholder")}
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
value={password}
|
||||
@@ -136,7 +136,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
||||
setPassword(text);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("login.password")}
|
||||
placeholder={t("login.password_placeholder")}
|
||||
placeholderTextColor='#6B7280'
|
||||
secureTextEntry
|
||||
autoFocus
|
||||
@@ -174,7 +174,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
) : (
|
||||
t("login.login")
|
||||
t("common.login")
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -35,9 +35,9 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { chromecast } from "../utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "../utils/profiles/chromecasth265";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
@@ -73,10 +73,19 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
setLoadingServer(server.address);
|
||||
try {
|
||||
await onQuickLogin(server.address, account.userId);
|
||||
} catch {
|
||||
Alert.alert(
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("server.session_expired");
|
||||
const isSessionExpired = errorMessage.includes(
|
||||
t("server.session_expired"),
|
||||
t("server.please_login_again"),
|
||||
);
|
||||
Alert.alert(
|
||||
isSessionExpired
|
||||
? t("server.session_expired")
|
||||
: t("login.connection_failed"),
|
||||
isSessionExpired ? t("server.please_login_again") : errorMessage,
|
||||
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
||||
);
|
||||
} finally {
|
||||
@@ -122,10 +131,17 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
setLoadingServer(selectedServer.address);
|
||||
try {
|
||||
await onQuickLogin(selectedServer.address, selectedAccount.userId);
|
||||
} catch {
|
||||
Alert.alert(
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t("server.session_expired");
|
||||
const isSessionExpired = errorMessage.includes(
|
||||
t("server.session_expired"),
|
||||
t("server.please_login_again"),
|
||||
);
|
||||
Alert.alert(
|
||||
isSessionExpired
|
||||
? t("server.session_expired")
|
||||
: t("login.connection_failed"),
|
||||
isSessionExpired ? t("server.please_login_again") : errorMessage,
|
||||
[
|
||||
{
|
||||
text: t("common.ok"),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useMemo } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {
|
||||
@@ -55,23 +55,23 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SeerrRatings: React.FC<{
|
||||
export const JellyserrRatings: React.FC<{
|
||||
result: MovieResult | TvResult | TvDetails | MovieDetails;
|
||||
}> = ({ result }) => {
|
||||
const { seerrApi, getMediaType } = useSeerr();
|
||||
const { jellyseerrApi, getMediaType } = useJellyseerr();
|
||||
|
||||
const mediaType = useMemo(() => getMediaType(result), [result]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["seerr", result.id, mediaType, "ratings"],
|
||||
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
|
||||
queryFn: async () => {
|
||||
return mediaType === MediaType.MOVIE
|
||||
? seerrApi?.movieRatings(result.id)
|
||||
: seerrApi?.tvRatings(result.id);
|
||||
? jellyseerrApi?.movieRatings(result.id)
|
||||
: jellyseerrApi?.tvRatings(result.id);
|
||||
},
|
||||
staleTime: (5).minutesToMilliseconds(),
|
||||
retry: false,
|
||||
enabled: !!seerrApi,
|
||||
enabled: !!jellyseerrApi,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,11 +6,8 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||
|
||||
import { HorizontalScroll } from "./common/HorizontalScroll";
|
||||
import { Text } from "./common/Text";
|
||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
|
||||
const streams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||
[source, streamType],
|
||||
[source],
|
||||
);
|
||||
|
||||
const selectedSteam = useMemo(
|
||||
|
||||
@@ -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 &&
|
||||
|
||||