mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-14 05:16:25 +00:00
Compare commits
94 Commits
feat/tv-in
...
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 | ||
|
|
358e00d8b7 |
103
.claude/agents/tv-validator.md
Normal file
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
|
## 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
|
2. Review this conversation for learnings worth preserving
|
||||||
3. For each new fact:
|
3. For each new fact:
|
||||||
- Write it concisely (1-2 sentences max)
|
- Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below
|
||||||
- Include context for why it matters
|
- Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md`
|
||||||
- Add today's date
|
|
||||||
4. Skip facts that duplicate existing entries
|
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:
|
Create each file at `.claude/learned-facts/[kebab-case-name].md`:
|
||||||
```
|
|
||||||
- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_
|
```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)_
|
Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`:
|
||||||
- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_
|
|
||||||
- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_
|
|
||||||
|
|
||||||
After updating the file, summarize what facts you added (or note if nothing new was learned this session).
|
```
|
||||||
|
- `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
|
## Facts
|
||||||
|
|
||||||
@@ -31,3 +34,15 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
|
|||||||
- **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)_
|
- **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)_
|
- **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
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
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
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
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
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
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
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
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
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
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`.
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -61,6 +61,8 @@ expo-env.d.ts
|
|||||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||||
credentials.json
|
credentials.json
|
||||||
streamyfin-4fec1-firebase-adminsdk.json
|
streamyfin-4fec1-firebase-adminsdk.json
|
||||||
|
/profiles/
|
||||||
|
certs/
|
||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
|
|||||||
136
CLAUDE.md
136
CLAUDE.md
@@ -1,9 +1,39 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
@.claude/learned-facts.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
|
## Project Overview
|
||||||
|
|
||||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
|
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.
|
||||||
@@ -65,6 +95,7 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
|
|||||||
**State Management**:
|
**State Management**:
|
||||||
- Global state uses Jotai atoms in `utils/atoms/`
|
- Global state uses Jotai atoms in `utils/atoms/`
|
||||||
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
|
- `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
|
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
|
||||||
- Server state uses React Query with `@tanstack/react-query`
|
- Server state uses React Query with `@tanstack/react-query`
|
||||||
|
|
||||||
@@ -128,6 +159,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
- Handle both mobile and TV navigation patterns
|
- Handle both mobile and TV navigation patterns
|
||||||
- Use existing atoms, hooks, and utilities before creating new ones
|
- Use existing atoms, hooks, and utilities before creating new ones
|
||||||
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
|
- 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
|
## Platform Considerations
|
||||||
|
|
||||||
@@ -138,13 +170,13 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
|
- **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 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 Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
|
||||||
- **TV Modals**: Never use overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference.
|
- **TV 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
|
### TV Component Rendering Pattern
|
||||||
|
|
||||||
**IMPORTANT**: The `.tv.tsx` file suffix only works for **pages** in the `app/` directory (resolved by Expo Router). It does NOT work for components - Metro bundler doesn't resolve platform-specific suffixes for component imports.
|
**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 components**:
|
**Pattern for TV-specific pages and components**:
|
||||||
```typescript
|
```typescript
|
||||||
// In page file (e.g., app/login.tsx)
|
// In page file (e.g., app/login.tsx)
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
@@ -164,99 +196,11 @@ export default LoginPage;
|
|||||||
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
|
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
|
||||||
- Use `Platform.isTV` to conditionally render the appropriate component
|
- Use `Platform.isTV` to conditionally render the appropriate component
|
||||||
- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling
|
- 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 Selector Pattern (Dropdowns/Multi-select)
|
### TV Option Selectors and Focus Management
|
||||||
|
|
||||||
For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because:
|
For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md).
|
||||||
- Horizontal scrolling is natural for TV remotes (left/right D-pad)
|
|
||||||
- Bottom sheet takes minimal screen space
|
|
||||||
- Focus-based navigation works reliably
|
|
||||||
|
|
||||||
**Key implementation details:**
|
|
||||||
|
|
||||||
1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain. Use an absolutely positioned `View` overlay instead:
|
|
||||||
```typescript
|
|
||||||
<View style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0, left: 0, right: 0, bottom: 0,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
zIndex: 1000,
|
|
||||||
}}>
|
|
||||||
<BlurView intensity={80} tint="dark" style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24 }}>
|
|
||||||
{/* Content */}
|
|
||||||
</BlurView>
|
|
||||||
</View>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Horizontal ScrollView with focusable cards**:
|
|
||||||
```typescript
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
style={{ overflow: "visible" }}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 48, paddingVertical: 10, gap: 12 }}
|
|
||||||
>
|
|
||||||
{options.map((option, index) => (
|
|
||||||
<TVOptionCard
|
|
||||||
key={index}
|
|
||||||
hasTVPreferredFocus={index === selectedIndex}
|
|
||||||
onPress={() => { onSelect(option.value); onClose(); }}
|
|
||||||
// ...
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`:
|
|
||||||
```typescript
|
|
||||||
<Pressable
|
|
||||||
onPress={onPress}
|
|
||||||
onFocus={() => { setFocused(true); animateTo(1.05); }}
|
|
||||||
onBlur={() => { setFocused(false); animateTo(1); }}
|
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
||||||
>
|
|
||||||
<Animated.View style={{ transform: [{ scale }], backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)" }}>
|
|
||||||
<Text style={{ color: focused ? "#000" : "#fff" }}>{label}</Text>
|
|
||||||
</Animated.View>
|
|
||||||
</Pressable>
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip.
|
|
||||||
|
|
||||||
**Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`
|
|
||||||
|
|
||||||
### TV Focus Management for Overlays/Modals
|
|
||||||
|
|
||||||
**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation.
|
|
||||||
|
|
||||||
**Solution**: Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1. Track modal state
|
|
||||||
const [openModal, setOpenModal] = useState<ModalType | null>(null);
|
|
||||||
const isModalOpen = openModal !== null;
|
|
||||||
|
|
||||||
// 2. Each focusable component accepts disabled prop
|
|
||||||
const TVFocusableButton: React.FC<{
|
|
||||||
onPress: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}> = ({ onPress, disabled }) => (
|
|
||||||
<Pressable
|
|
||||||
onPress={onPress}
|
|
||||||
disabled={disabled}
|
|
||||||
focusable={!disabled}
|
|
||||||
hasTVPreferredFocus={isFirst && !disabled}
|
|
||||||
>
|
|
||||||
{/* content */}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Pass disabled to all background components when modal is open
|
|
||||||
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
|
|
||||||
```
|
|
||||||
|
|
||||||
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
|
|
||||||
|
|
||||||
### TV Focus Flickering Between Zones (Lists with Headers)
|
### TV Focus Flickering Between Zones (Lists with Headers)
|
||||||
|
|
||||||
|
|||||||
1
app.json
1
app.json
@@ -76,6 +76,7 @@
|
|||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.js",
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
|
"./plugins/withTVUserManagement.js",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Home } from "../../../../components/home/Home";
|
import { Home } from "../../../../components/home/Home";
|
||||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const { settings } = useSettings();
|
|
||||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
|
||||||
|
|
||||||
if (showLargeHomeCarousel) {
|
|
||||||
return <HomeWithCarousel />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Home />;
|
return <Home />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
import type { TVOptionItem } from "@/components/tv";
|
import type { TVOptionItem } from "@/components/tv";
|
||||||
import {
|
import {
|
||||||
TVLogoutButton,
|
TVLogoutButton,
|
||||||
@@ -15,30 +17,161 @@ import {
|
|||||||
TVSettingsTextInput,
|
TVSettingsTextInput,
|
||||||
TVSettingsToggle,
|
TVSettingsToggle,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||||
|
import { APP_LANGUAGES } from "@/i18n";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
import {
|
||||||
|
AudioTranscodeMode,
|
||||||
|
InactivityTimeout,
|
||||||
|
type MpvCacheMode,
|
||||||
|
TVTypographyScale,
|
||||||
|
useSettings,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
getPreviousServers,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
|
||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { logout } = useJellyfin();
|
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
// Local state for OpenSubtitles API key (only commit on blur)
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
settings.openSubtitlesApiKey || "",
|
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 =
|
const currentAudioTranscode =
|
||||||
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
|
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
|
||||||
const currentSubtitleMode =
|
const currentSubtitleMode =
|
||||||
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
||||||
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
||||||
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||||
|
const currentTypographyScale =
|
||||||
|
settings.tvTypographyScale || TVTypographyScale.Default;
|
||||||
|
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
|
||||||
|
const currentLanguage = settings.preferedLanguage;
|
||||||
|
|
||||||
// Audio transcoding options
|
// Audio transcoding options
|
||||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
@@ -130,6 +263,123 @@ export default function SettingsTV() {
|
|||||||
[currentAlignY],
|
[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
|
// Get display labels for option buttons
|
||||||
const audioTranscodeLabel = useMemo(() => {
|
const audioTranscodeLabel = useMemo(() => {
|
||||||
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
||||||
@@ -151,6 +401,29 @@ export default function SettingsTV() {
|
|||||||
return option?.label || "Bottom";
|
return option?.label || "Bottom";
|
||||||
}, [alignYOptions]);
|
}, [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 (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
@@ -166,7 +439,7 @@ export default function SettingsTV() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 42,
|
fontSize: typography.title,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -175,6 +448,31 @@ export default function SettingsTV() {
|
|||||||
{t("home.settings.settings_title")}
|
{t("home.settings.settings_title")}
|
||||||
</Text>
|
</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 */}
|
{/* Audio Section */}
|
||||||
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
@@ -188,7 +486,6 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ audioTranscodeMode: value }),
|
updateSettings({ audioTranscodeMode: value }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
isFirst
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Subtitles Section */}
|
{/* Subtitles Section */}
|
||||||
@@ -215,26 +512,10 @@ export default function SettingsTV() {
|
|||||||
/>
|
/>
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.subtitles.subtitle_size")}
|
label={t("home.settings.subtitles.subtitle_size")}
|
||||||
value={settings.subtitleSize / 100}
|
|
||||||
onDecrease={() => {
|
|
||||||
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
|
|
||||||
updateSettings({ subtitleSize: Math.round(newValue * 100) });
|
|
||||||
}}
|
|
||||||
onIncrease={() => {
|
|
||||||
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
|
|
||||||
updateSettings({ subtitleSize: Math.round(newValue * 100) });
|
|
||||||
}}
|
|
||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* MPV Subtitles Section */}
|
|
||||||
<TVSectionHeader title='MPV Subtitle Settings' />
|
|
||||||
<TVSettingsStepper
|
|
||||||
label='Subtitle Scale'
|
|
||||||
value={settings.mpvSubtitleScale ?? 1.0}
|
value={settings.mpvSubtitleScale ?? 1.0}
|
||||||
onDecrease={() => {
|
onDecrease={() => {
|
||||||
const newValue = Math.max(
|
const newValue = Math.max(
|
||||||
0.5,
|
0.1,
|
||||||
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
|
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
|
||||||
);
|
);
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -243,7 +524,7 @@ export default function SettingsTV() {
|
|||||||
}}
|
}}
|
||||||
onIncrease={() => {
|
onIncrease={() => {
|
||||||
const newValue = Math.min(
|
const newValue = Math.min(
|
||||||
2.0,
|
3.0,
|
||||||
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
|
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
|
||||||
);
|
);
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -309,7 +590,7 @@ export default function SettingsTV() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
fontSize: 14,
|
fontSize: typography.callout - 2,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -333,7 +614,7 @@ export default function SettingsTV() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
fontSize: 12,
|
fontSize: typography.callout - 4,
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -342,8 +623,103 @@ export default function SettingsTV() {
|
|||||||
"Get your free API key at opensubtitles.com/en/consumers"}
|
"Get your free API key at opensubtitles.com/en/consumers"}
|
||||||
</Text>
|
</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 */}
|
{/* Appearance Section */}
|
||||||
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
<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
|
<TVSettingsToggle
|
||||||
label={t(
|
label={t(
|
||||||
"home.settings.appearance.merge_next_up_continue_watching",
|
"home.settings.appearance.merge_next_up_continue_watching",
|
||||||
@@ -358,6 +734,23 @@ export default function SettingsTV() {
|
|||||||
value={settings.showHomeBackdrop}
|
value={settings.showHomeBackdrop}
|
||||||
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
|
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 */}
|
{/* User Section */}
|
||||||
<TVSectionHeader
|
<TVSectionHeader
|
||||||
@@ -380,6 +773,37 @@ export default function SettingsTV() {
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { GestureControls } from "@/components/settings/GestureControls";
|
import { GestureControls } from "@/components/settings/GestureControls";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
|
import { MpvBufferSettings } from "@/components/settings/MpvBufferSettings";
|
||||||
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
||||||
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export default function PlaybackControlsPage() {
|
|||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
<PlaybackControlsSettings />
|
<PlaybackControlsSettings />
|
||||||
|
<MpvBufferSettings />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
|
|||||||
@@ -15,14 +15,24 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { 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 * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -36,19 +46,29 @@ import {
|
|||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 16;
|
||||||
|
const TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
const { collectionId } = searchParams as { collectionId: string };
|
||||||
|
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
const [orientation, _setOrientation] = useState(
|
const [orientation, _setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
@@ -56,7 +76,7 @@ const page: React.FC = () => {
|
|||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection, isLoading: isCollectionLoading } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -71,6 +91,46 @@ const page: React.FC = () => {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TV Filter queries
|
||||||
|
const { data: tvGenreOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvYearOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Years || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvTagOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({ title: collection?.Name || "" });
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
setSortOrder([SortOrderOption.Ascending]);
|
setSortOrder([SortOrderOption.Ascending]);
|
||||||
@@ -87,6 +147,18 @@ const page: React.FC = () => {
|
|||||||
setSortBy([sortByOption]);
|
setSortBy([sortByOption]);
|
||||||
}, [navigation, collection]);
|
}, [navigation, collection]);
|
||||||
|
|
||||||
|
// Calculate columns for TV grid
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
const itemWidth = 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(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
@@ -98,7 +170,7 @@ const page: React.FC = () => {
|
|||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
limit: 18,
|
limit: Platform.isTV ? 36 : 18,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||||
sortBy: [sortBy[0]],
|
sortBy: [sortBy[0]],
|
||||||
@@ -123,6 +195,7 @@ const page: React.FC = () => {
|
|||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
collection,
|
collection,
|
||||||
|
collectionId,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -131,39 +204,40 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
queryKey: [
|
useInfiniteQuery({
|
||||||
"collection-items",
|
queryKey: [
|
||||||
collection,
|
"collection-items",
|
||||||
selectedGenres,
|
collectionId,
|
||||||
selectedYears,
|
selectedGenres,
|
||||||
selectedTags,
|
selectedYears,
|
||||||
sortBy,
|
selectedTags,
|
||||||
sortOrder,
|
sortBy,
|
||||||
],
|
sortOrder,
|
||||||
queryFn: fetchItems,
|
],
|
||||||
getNextPageParam: (lastPage, pages) => {
|
queryFn: fetchItems,
|
||||||
if (
|
getNextPageParam: (lastPage, pages) => {
|
||||||
!lastPage?.Items ||
|
if (
|
||||||
!lastPage?.TotalRecordCount ||
|
!lastPage?.Items ||
|
||||||
lastPage?.TotalRecordCount === 0
|
!lastPage?.TotalRecordCount ||
|
||||||
)
|
lastPage?.TotalRecordCount === 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
|
const accumulatedItems = pages.reduce(
|
||||||
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accumulatedItems < totalItems) {
|
||||||
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
},
|
||||||
const totalItems = lastPage.TotalRecordCount;
|
initialPageParam: 0,
|
||||||
const accumulatedItems = pages.reduce(
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
});
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
|
||||||
return lastPage?.Items?.length * pages.length;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -195,7 +269,6 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
{/* <MoviePoster item={item} /> */}
|
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
@@ -203,9 +276,34 @@ const page: React.FC = () => {
|
|||||||
[orientation],
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const renderTVItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navTarget = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
const _insets = useSafeAreaInsets();
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginRight: TV_ITEM_GAP,
|
||||||
|
marginBottom: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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(
|
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;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
// Mobile return
|
||||||
<FlashList
|
if (!Platform.isTV) {
|
||||||
ListEmptyComponent={
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<FlashList
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
ListEmptyComponent={
|
||||||
{t("search.no_results")}
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
</View>
|
{t("search.no_results")}
|
||||||
}
|
</Text>
|
||||||
extraData={[
|
</View>
|
||||||
selectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
selectedTags,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
]}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
data={flatData}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
numColumns={
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
}
|
||||||
}}
|
extraData={[
|
||||||
onEndReachedThreshold={0.5}
|
selectedGenres,
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
selectedYears,
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
selectedTags,
|
||||||
ItemSeparatorComponent={() => (
|
sortBy,
|
||||||
<View
|
sortOrder,
|
||||||
style={{
|
]}
|
||||||
width: 10,
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
height: 10,
|
data={flatData}
|
||||||
}}
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV return with filter bar
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Filter bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
marginTop: insets.top + 100,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingHorizontal: TV_SCALE_PADDING,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowGenreFilter}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
/>
|
/>
|
||||||
)}
|
<TVFilterButton
|
||||||
/>
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowYearFilter}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowTagFilter}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={handleShowSortByFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={handleShowSortOrderFilter}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<FlatList
|
||||||
|
key={`${orientation}-${nrOfCols}`}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("search.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: TV_SCALE_PADDING,
|
||||||
|
paddingRight: TV_SCALE_PADDING,
|
||||||
|
paddingTop: 20,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type {
|
|||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} 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();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
@@ -19,6 +20,17 @@ export const Tab = withLayoutContext<
|
|||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
const Layout = () => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Live TV" }} />
|
<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 { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVLiveTVPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MobileLiveTVPrograms />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileLiveTVPrograms() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +118,8 @@ const page: React.FC = () => {
|
|||||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
),
|
),
|
||||||
staleTime: isOffline ? Infinity : 60,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -12,23 +11,14 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { BlurView } from "expo-blur";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Animated,
|
|
||||||
Easing,
|
|
||||||
FlatList,
|
FlatList,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
@@ -44,13 +34,14 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
import MoviePoster, {
|
import { TVFilterButton, TVFocusablePoster } from "@/components/tv";
|
||||||
TV_POSTER_WIDTH,
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
} from "@/components/posters/MoviePoster.tv";
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -74,280 +65,13 @@ import {
|
|||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
const TV_ITEM_GAP = 16;
|
const TV_ITEM_GAP = 20;
|
||||||
const TV_SCALE_PADDING = 20;
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
const _TV_SCALE_PADDING = 20;
|
||||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
|
const TV_PLAYLIST_SQUARE_SIZE = 180;
|
||||||
<View style={{ marginTop: 12 }}>
|
|
||||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// TV Filter Types and Components
|
|
||||||
type TVFilterModalType =
|
|
||||||
| "genre"
|
|
||||||
| "year"
|
|
||||||
| "tags"
|
|
||||||
| "sortBy"
|
|
||||||
| "sortOrder"
|
|
||||||
| "filterBy"
|
|
||||||
| null;
|
|
||||||
|
|
||||||
interface TVFilterOption<T> {
|
|
||||||
label: string;
|
|
||||||
value: T;
|
|
||||||
selected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TVFilterOptionCard: React.FC<{
|
|
||||||
label: string;
|
|
||||||
selected: boolean;
|
|
||||||
hasTVPreferredFocus?: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
|
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
|
||||||
|
|
||||||
const animateTo = (v: number) =>
|
|
||||||
Animated.timing(scale, {
|
|
||||||
toValue: v,
|
|
||||||
duration: 150,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={onPress}
|
|
||||||
onFocus={() => {
|
|
||||||
setFocused(true);
|
|
||||||
animateTo(1.05);
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
setFocused(false);
|
|
||||||
animateTo(1);
|
|
||||||
}}
|
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
transform: [{ scale }],
|
|
||||||
width: 160,
|
|
||||||
height: 75,
|
|
||||||
backgroundColor: focused
|
|
||||||
? "#fff"
|
|
||||||
: selected
|
|
||||||
? "rgba(255,255,255,0.2)"
|
|
||||||
: "rgba(255,255,255,0.08)",
|
|
||||||
borderRadius: 14,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 16,
|
|
||||||
color: focused ? "#000" : "#fff",
|
|
||||||
fontWeight: focused || selected ? "600" : "400",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
{selected && !focused && (
|
|
||||||
<View style={{ position: "absolute", top: 8, right: 8 }}>
|
|
||||||
<Ionicons
|
|
||||||
name='checkmark'
|
|
||||||
size={16}
|
|
||||||
color='rgba(255,255,255,0.8)'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TVFilterButton: React.FC<{
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onPress: () => void;
|
|
||||||
hasTVPreferredFocus?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
hasActiveFilter?: boolean;
|
|
||||||
}> = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onPress,
|
|
||||||
hasTVPreferredFocus,
|
|
||||||
disabled,
|
|
||||||
hasActiveFilter,
|
|
||||||
}) => {
|
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
|
||||||
|
|
||||||
const animateTo = (v: number) =>
|
|
||||||
Animated.timing(scale, {
|
|
||||||
toValue: v,
|
|
||||||
duration: 120,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={onPress}
|
|
||||||
onFocus={() => {
|
|
||||||
setFocused(true);
|
|
||||||
animateTo(1.04);
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
setFocused(false);
|
|
||||||
animateTo(1);
|
|
||||||
}}
|
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
|
||||||
disabled={disabled}
|
|
||||||
focusable={!disabled}
|
|
||||||
>
|
|
||||||
<Animated.View style={{ transform: [{ scale }] }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: focused
|
|
||||||
? "#fff"
|
|
||||||
: hasActiveFilter
|
|
||||||
? "rgba(255, 255, 255, 0.25)"
|
|
||||||
: "rgba(255,255,255,0.1)",
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
borderWidth: hasActiveFilter && !focused ? 1 : 0,
|
|
||||||
borderColor: "rgba(255, 255, 255, 0.4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label ? (
|
|
||||||
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: focused ? "#000" : "#FFFFFF",
|
|
||||||
fontWeight: "500",
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TVFilterSelector = <T,>({
|
|
||||||
visible,
|
|
||||||
title,
|
|
||||||
options,
|
|
||||||
onSelect,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
visible: boolean;
|
|
||||||
title: string;
|
|
||||||
options: TVFilterOption<T>[];
|
|
||||||
onSelect: (value: T) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) => {
|
|
||||||
// Track initial focus index - only set once when modal opens
|
|
||||||
const initialFocusIndexRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// Calculate initial focus index only once when visible becomes true
|
|
||||||
if (visible && initialFocusIndexRef.current === null) {
|
|
||||||
const idx = options.findIndex((o) => o.selected);
|
|
||||||
initialFocusIndexRef.current = idx >= 0 ? idx : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset when modal closes
|
|
||||||
if (!visible) {
|
|
||||||
initialFocusIndexRef.current = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialFocusIndex = initialFocusIndexRef.current ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BlurView
|
|
||||||
intensity={80}
|
|
||||||
tint='dark'
|
|
||||||
style={{
|
|
||||||
borderTopLeftRadius: 24,
|
|
||||||
borderTopRightRadius: 24,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ paddingVertical: 24 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#fff",
|
|
||||||
paddingHorizontal: 48,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
style={{ overflow: "visible" }}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: 48,
|
|
||||||
paddingVertical: 10,
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{options.map((option, index) => (
|
|
||||||
<TVFilterOptionCard
|
|
||||||
key={String(option.value)}
|
|
||||||
label={option.label}
|
|
||||||
selected={option.selected}
|
|
||||||
hasTVPreferredFocus={index === initialFocusIndex}
|
|
||||||
onPress={() => {
|
|
||||||
onSelect(option.value);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</BlurView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams() as {
|
const searchParams = useLocalSearchParams() as {
|
||||||
@@ -358,6 +82,8 @@ const Page = () => {
|
|||||||
};
|
};
|
||||||
const { libraryId } = searchParams;
|
const { libraryId } = searchParams;
|
||||||
|
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
@@ -380,13 +106,8 @@ const Page = () => {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
// TV Filter modal state
|
const { showItemActions } = useTVItemActionModal();
|
||||||
const [openFilterModal, setOpenFilterModal] =
|
|
||||||
useState<TVFilterModalType>(null);
|
|
||||||
const isFilterModalOpen = openFilterModal !== null;
|
|
||||||
|
|
||||||
const isFiltersDisabled = isFilterModalOpen;
|
|
||||||
|
|
||||||
// TV Filter queries
|
// TV Filter queries
|
||||||
const { data: tvGenreOptions } = useQuery({
|
const { data: tvGenreOptions } = useQuery({
|
||||||
@@ -511,12 +232,8 @@ const Page = () => {
|
|||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
// Calculate columns based on TV poster width + gap
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
return 1;
|
||||||
return Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
@@ -569,6 +286,8 @@ const Page = () => {
|
|||||||
itemType = "Video";
|
itemType = "Video";
|
||||||
} else if (library.CollectionType === "musicvideos") {
|
} else if (library.CollectionType === "musicvideos") {
|
||||||
itemType = "MusicVideo";
|
itemType = "MusicVideo";
|
||||||
|
} else if (library.CollectionType === "playlists") {
|
||||||
|
itemType = "Playlist";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
@@ -588,6 +307,9 @@ const Page = () => {
|
|||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||||
includeItemTypes: itemType ? [itemType] : undefined,
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
|
...(Platform.isTV && library.CollectionType === "playlists"
|
||||||
|
? { mediaTypes: ["Video"] }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -682,34 +404,84 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTVItem = useCallback(
|
const renderTVItem = useCallback(
|
||||||
({ item }: { item: BaseItemDto }) => {
|
(item: BaseItemDto) => {
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
|
if (item.Type === "Playlist") {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||||
|
params: { libraryId: item.Id! },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const navTarget = getItemNavigation(item, "(libraries)");
|
const navTarget = getItemNavigation(item, "(libraries)");
|
||||||
router.push(navTarget as any);
|
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 (
|
return (
|
||||||
<View
|
<TVPosterCard
|
||||||
style={{
|
key={item.Id}
|
||||||
marginRight: TV_ITEM_GAP,
|
item={item}
|
||||||
marginBottom: TV_ITEM_GAP,
|
orientation='vertical'
|
||||||
width: TV_POSTER_WIDTH,
|
onPress={handlePress}
|
||||||
}}
|
onLongPress={() => showItemActions(item)}
|
||||||
>
|
width={posterSizes.poster}
|
||||||
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
|
/>
|
||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
|
||||||
{(item.Type === "Series" || item.Type === "Episode") && (
|
|
||||||
<SeriesPoster item={item} />
|
|
||||||
)}
|
|
||||||
{item.Type !== "Movie" &&
|
|
||||||
item.Type !== "Series" &&
|
|
||||||
item.Type !== "Episode" && <MoviePoster item={item} />}
|
|
||||||
</TVFocusablePoster>
|
|
||||||
<TVItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[router, isFilterModalOpen],
|
[router, showItemActions, api, typography],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
@@ -912,7 +684,7 @@ const Page = () => {
|
|||||||
|
|
||||||
// TV Filter options - with "All" option for clearable filters
|
// TV Filter options - with "All" option for clearable filters
|
||||||
const tvGenreFilterOptions = useMemo(
|
const tvGenreFilterOptions = useMemo(
|
||||||
(): TVFilterOption<string>[] => [
|
(): TVOptionItem<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -928,7 +700,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvYearFilterOptions = useMemo(
|
const tvYearFilterOptions = useMemo(
|
||||||
(): TVFilterOption<string>[] => [
|
(): TVOptionItem<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -944,7 +716,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvTagFilterOptions = useMemo(
|
const tvTagFilterOptions = useMemo(
|
||||||
(): TVFilterOption<string>[] => [
|
(): TVOptionItem<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -960,7 +732,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvSortByOptions = useMemo(
|
const tvSortByOptions = useMemo(
|
||||||
(): TVFilterOption<SortByOption>[] =>
|
(): TVOptionItem<SortByOption>[] =>
|
||||||
sortOptions.map((option) => ({
|
sortOptions.map((option) => ({
|
||||||
label: option.value,
|
label: option.value,
|
||||||
value: option.key,
|
value: option.key,
|
||||||
@@ -970,7 +742,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvSortOrderOptions = useMemo(
|
const tvSortOrderOptions = useMemo(
|
||||||
(): TVFilterOption<SortOrderOption>[] =>
|
(): TVOptionItem<SortOrderOption>[] =>
|
||||||
sortOrderOptions.map((option) => ({
|
sortOrderOptions.map((option) => ({
|
||||||
label: option.value,
|
label: option.value,
|
||||||
value: option.key,
|
value: option.key,
|
||||||
@@ -980,7 +752,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvFilterByOptions = useMemo(
|
const tvFilterByOptions = useMemo(
|
||||||
(): TVFilterOption<string>[] => [
|
(): TVOptionItem<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -995,56 +767,88 @@ const Page = () => {
|
|||||||
[filterBy, generalFilters, t],
|
[filterBy, generalFilters, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TV Filter handlers
|
// TV Filter handlers using navigation-based modal
|
||||||
const handleGenreSelect = useCallback(
|
const handleShowGenreFilter = useCallback(() => {
|
||||||
(value: string) => {
|
showOptions({
|
||||||
if (value === "__all__") {
|
title: t("library.filters.genres"),
|
||||||
setSelectedGenres([]);
|
options: tvGenreFilterOptions,
|
||||||
} else if (selectedGenres.includes(value)) {
|
onSelect: (value: string) => {
|
||||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
if (value === "__all__") {
|
||||||
} else {
|
setSelectedGenres([]);
|
||||||
setSelectedGenres([...selectedGenres, value]);
|
} else if (selectedGenres.includes(value)) {
|
||||||
}
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
},
|
} else {
|
||||||
[selectedGenres, setSelectedGenres],
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
);
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
const handleYearSelect = useCallback(
|
const handleShowYearFilter = useCallback(() => {
|
||||||
(value: string) => {
|
showOptions({
|
||||||
if (value === "__all__") {
|
title: t("library.filters.years"),
|
||||||
setSelectedYears([]);
|
options: tvYearFilterOptions,
|
||||||
} else if (selectedYears.includes(value)) {
|
onSelect: (value: string) => {
|
||||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
if (value === "__all__") {
|
||||||
} else {
|
setSelectedYears([]);
|
||||||
setSelectedYears([...selectedYears, value]);
|
} else if (selectedYears.includes(value)) {
|
||||||
}
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
},
|
} else {
|
||||||
[selectedYears, setSelectedYears],
|
setSelectedYears([...selectedYears, value]);
|
||||||
);
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
const handleTagSelect = useCallback(
|
const handleShowTagFilter = useCallback(() => {
|
||||||
(value: string) => {
|
showOptions({
|
||||||
if (value === "__all__") {
|
title: t("library.filters.tags"),
|
||||||
setSelectedTags([]);
|
options: tvTagFilterOptions,
|
||||||
} else if (selectedTags.includes(value)) {
|
onSelect: (value: string) => {
|
||||||
setSelectedTags(selectedTags.filter((t) => t !== value));
|
if (value === "__all__") {
|
||||||
} else {
|
setSelectedTags([]);
|
||||||
setSelectedTags([...selectedTags, value]);
|
} else if (selectedTags.includes(value)) {
|
||||||
}
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
},
|
} else {
|
||||||
[selectedTags, setSelectedTags],
|
setSelectedTags([...selectedTags, value]);
|
||||||
);
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
const handleFilterBySelect = useCallback(
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
(value: string) => {
|
showOptions({
|
||||||
if (value === "__all__") {
|
title: t("library.filters.sort_by"),
|
||||||
_setFilterBy([]);
|
options: tvSortByOptions,
|
||||||
} else {
|
onSelect: (value: SortByOption) => {
|
||||||
setFilter([value as FilterByOption]);
|
setSortBy([value]);
|
||||||
}
|
},
|
||||||
},
|
});
|
||||||
[setFilter, _setFilterBy],
|
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||||
);
|
|
||||||
|
const handleShowSortOrderFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_order"),
|
||||||
|
options: tvSortOrderOptions,
|
||||||
|
onSelect: (value: SortOrderOption) => {
|
||||||
|
setSortOrder([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||||
|
|
||||||
|
const handleShowFilterByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.filter_by"),
|
||||||
|
options: tvFilterByOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
_setFilterBy([]);
|
||||||
|
} else {
|
||||||
|
setFilter([value as FilterByOption]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -1097,185 +901,134 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
|
// TV return with filter bar
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<ScrollView
|
||||||
{/* Background content - disabled when modal is open */}
|
style={{ flex: 1 }}
|
||||||
<View
|
contentContainerStyle={{
|
||||||
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
|
paddingTop: insets.top + 100,
|
||||||
focusable={!isFilterModalOpen}
|
paddingBottom: insets.bottom + 60,
|
||||||
isTVSelectable={!isFilterModalOpen}
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
pointerEvents={isFilterModalOpen ? "none" : "auto"}
|
}}
|
||||||
accessibilityElementsHidden={isFilterModalOpen}
|
onScroll={({ nativeEvent }) => {
|
||||||
importantForAccessibility={
|
// Load more when near bottom
|
||||||
isFilterModalOpen ? "no-hide-descendants" : "auto"
|
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||||
|
const isNearBottom =
|
||||||
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
|
contentSize.height - 500;
|
||||||
|
if (isNearBottom && hasNextPage && !isFetching) {
|
||||||
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={400}
|
||||||
|
>
|
||||||
|
{/* Filter bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingBottom: 24,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
|
{hasActiveFilters && (
|
||||||
<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}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TVFilterButton
|
<TVFilterButton
|
||||||
label={t("library.filters.genres")}
|
label=''
|
||||||
value={
|
value={t("library.filters.reset")}
|
||||||
selectedGenres.length > 0
|
onPress={resetAllFilters}
|
||||||
? `${selectedGenres.length} selected`
|
hasActiveFilter
|
||||||
: t("library.filters.all")
|
|
||||||
}
|
|
||||||
onPress={() => setOpenFilterModal("genre")}
|
|
||||||
hasTVPreferredFocus={!hasActiveFilters}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter={selectedGenres.length > 0}
|
|
||||||
/>
|
/>
|
||||||
<TVFilterButton
|
)}
|
||||||
label={t("library.filters.years")}
|
<TVFilterButton
|
||||||
value={
|
label={t("library.filters.genres")}
|
||||||
selectedYears.length > 0
|
value={
|
||||||
? `${selectedYears.length} selected`
|
selectedGenres.length > 0
|
||||||
: t("library.filters.all")
|
? `${selectedGenres.length} selected`
|
||||||
}
|
: t("library.filters.all")
|
||||||
onPress={() => setOpenFilterModal("year")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter={selectedYears.length > 0}
|
|
||||||
/>
|
|
||||||
<TVFilterButton
|
|
||||||
label={t("library.filters.tags")}
|
|
||||||
value={
|
|
||||||
selectedTags.length > 0
|
|
||||||
? `${selectedTags.length} selected`
|
|
||||||
: t("library.filters.all")
|
|
||||||
}
|
|
||||||
onPress={() => setOpenFilterModal("tags")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter={selectedTags.length > 0}
|
|
||||||
/>
|
|
||||||
<TVFilterButton
|
|
||||||
label={t("library.filters.sort_by")}
|
|
||||||
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
|
||||||
onPress={() => setOpenFilterModal("sortBy")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
/>
|
|
||||||
<TVFilterButton
|
|
||||||
label={t("library.filters.sort_order")}
|
|
||||||
value={
|
|
||||||
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
|
||||||
}
|
|
||||||
onPress={() => setOpenFilterModal("sortOrder")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
/>
|
|
||||||
<TVFilterButton
|
|
||||||
label={t("library.filters.filter_by")}
|
|
||||||
value={
|
|
||||||
filterBy.length > 0
|
|
||||||
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
|
||||||
: t("library.filters.all")
|
|
||||||
}
|
|
||||||
onPress={() => setOpenFilterModal("filterBy")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter={filterBy.length > 0}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
|
|
||||||
<FlatList
|
|
||||||
key={`${orientation}-${nrOfCols}`}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
|
||||||
{t("library.no_results")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
onPress={handleShowGenreFilter}
|
||||||
data={flatData}
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
renderItem={renderTVItem}
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
extraData={[orientation, nrOfCols, isFilterModalOpen]}
|
/>
|
||||||
keyExtractor={keyExtractor}
|
<TVFilterButton
|
||||||
numColumns={nrOfCols}
|
label={t("library.filters.years")}
|
||||||
removeClippedSubviews={false}
|
value={
|
||||||
onEndReached={() => {
|
selectedYears.length > 0
|
||||||
if (hasNextPage) {
|
? `${selectedYears.length} selected`
|
||||||
fetchNextPage();
|
: t("library.filters.all")
|
||||||
}
|
}
|
||||||
}}
|
onPress={handleShowYearFilter}
|
||||||
onEndReachedThreshold={1}
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
contentContainerStyle={{
|
/>
|
||||||
paddingBottom: 24,
|
<TVFilterButton
|
||||||
paddingLeft: TV_SCALE_PADDING,
|
label={t("library.filters.tags")}
|
||||||
paddingRight: TV_SCALE_PADDING,
|
value={
|
||||||
paddingTop: 20,
|
selectedTags.length > 0
|
||||||
}}
|
? `${selectedTags.length} selected`
|
||||||
ItemSeparatorComponent={() => (
|
: t("library.filters.all")
|
||||||
<View
|
}
|
||||||
style={{
|
onPress={handleShowTagFilter}
|
||||||
width: 10,
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
height: 10,
|
/>
|
||||||
}}
|
<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>
|
</View>
|
||||||
|
|
||||||
{/* TV Filter Overlays */}
|
{/* Grid with flexWrap */}
|
||||||
<TVFilterSelector
|
{flatData.length === 0 ? (
|
||||||
visible={openFilterModal === "genre"}
|
<View
|
||||||
title={t("library.filters.genres")}
|
style={{
|
||||||
options={tvGenreFilterOptions}
|
flex: 1,
|
||||||
onSelect={handleGenreSelect}
|
justifyContent: "center",
|
||||||
onClose={() => setOpenFilterModal(null)}
|
alignItems: "center",
|
||||||
/>
|
paddingTop: 100,
|
||||||
<TVFilterSelector
|
}}
|
||||||
visible={openFilterModal === "year"}
|
>
|
||||||
title={t("library.filters.years")}
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||||
options={tvYearFilterOptions}
|
{t("library.no_results")}
|
||||||
onSelect={handleYearSelect}
|
</Text>
|
||||||
onClose={() => setOpenFilterModal(null)}
|
</View>
|
||||||
/>
|
) : (
|
||||||
<TVFilterSelector
|
<View
|
||||||
visible={openFilterModal === "tags"}
|
style={{
|
||||||
title={t("library.filters.tags")}
|
flexDirection: "row",
|
||||||
options={tvTagFilterOptions}
|
flexWrap: "wrap",
|
||||||
onSelect={handleTagSelect}
|
justifyContent: "center",
|
||||||
onClose={() => setOpenFilterModal(null)}
|
gap: TV_ITEM_GAP,
|
||||||
/>
|
}}
|
||||||
<TVFilterSelector
|
>
|
||||||
visible={openFilterModal === "sortBy"}
|
{flatData.map((item) => renderTVItem(item))}
|
||||||
title={t("library.filters.sort_by")}
|
</View>
|
||||||
options={tvSortByOptions}
|
)}
|
||||||
onSelect={(value) => setSortBy([value])}
|
|
||||||
onClose={() => setOpenFilterModal(null)}
|
{/* Loading indicator */}
|
||||||
/>
|
{isFetching && (
|
||||||
<TVFilterSelector
|
<View style={{ paddingVertical: 20 }}>
|
||||||
visible={openFilterModal === "sortOrder"}
|
<Loader />
|
||||||
title={t("library.filters.sort_order")}
|
</View>
|
||||||
options={tvSortOrderOptions}
|
)}
|
||||||
onSelect={(value) => setSortOrder([value])}
|
</ScrollView>
|
||||||
onClose={() => setOpenFilterModal(null)}
|
|
||||||
/>
|
|
||||||
<TVFilterSelector
|
|
||||||
visible={openFilterModal === "filterBy"}
|
|
||||||
title={t("library.filters.filter_by")}
|
|
||||||
options={tvFilterByOptions}
|
|
||||||
onSelect={handleFilterBySelect}
|
|
||||||
onClose={() => setOpenFilterModal(null)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
|||||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -69,6 +70,7 @@ export default function search() {
|
|||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = (segments as string[])[2] || "(search)";
|
const from = (segments as string[])[2] || "(search)";
|
||||||
|
|
||||||
@@ -607,6 +609,7 @@ export default function search() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
noResults={noResults}
|
noResults={noResults}
|
||||||
onItemPress={handleItemPress}
|
onItemPress={handleItemPress}
|
||||||
|
onItemLongPress={showItemActions}
|
||||||
searchType={searchType}
|
searchType={searchType}
|
||||||
setSearchType={setSearchType}
|
setSearchType={setSearchType}
|
||||||
showDiscover={!!jellyseerrApi}
|
showDiscover={!!jellyseerrApi}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Platform,
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
@@ -23,13 +24,12 @@ import {
|
|||||||
} from "@/components/common/TouchableItemRouter";
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
import MoviePoster, {
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
TV_POSTER_WIDTH,
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
} from "@/components/posters/MoviePoster.tv";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import {
|
import {
|
||||||
useDeleteWatchlist,
|
useDeleteWatchlist,
|
||||||
useRemoveFromWatchlist,
|
useRemoveFromWatchlist,
|
||||||
@@ -41,23 +41,15 @@ import {
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const TV_ITEM_GAP = 16;
|
const TV_ITEM_GAP = 20;
|
||||||
const TV_SCALE_PADDING = 20;
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
|
|
||||||
<View style={{ marginTop: 12 }}>
|
|
||||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function WatchlistDetailScreen() {
|
export default function WatchlistDetailScreen() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||||
@@ -70,14 +62,8 @@ export default function WatchlistDetailScreen() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
if (Platform.isTV) {
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
// Calculate columns based on TV poster width + gap
|
if (Platform.isTV) return 1;
|
||||||
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
|
||||||
return Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -185,34 +171,25 @@ export default function WatchlistDetailScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTVItem = useCallback(
|
const renderTVItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
(item: BaseItemDto, index: number) => {
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
const navigation = getItemNavigation(item, "(watchlists)");
|
const navigation = getItemNavigation(item, "(watchlists)");
|
||||||
router.push(navigation as any);
|
router.push(navigation as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<TVPosterCard
|
||||||
style={{
|
key={item.Id}
|
||||||
marginRight: TV_ITEM_GAP,
|
item={item}
|
||||||
marginBottom: TV_ITEM_GAP,
|
orientation='vertical'
|
||||||
width: TV_POSTER_WIDTH,
|
onPress={handlePress}
|
||||||
}}
|
onLongPress={() => showItemActions(item)}
|
||||||
>
|
hasTVPreferredFocus={index === 0}
|
||||||
<TVFocusablePoster
|
width={posterSizes.poster}
|
||||||
onPress={handlePress}
|
/>
|
||||||
hasTVPreferredFocus={index === 0}
|
|
||||||
>
|
|
||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
|
||||||
{(item.Type === "Series" || item.Type === "Episode") && (
|
|
||||||
<SeriesPoster item={item} />
|
|
||||||
)}
|
|
||||||
</TVFocusablePoster>
|
|
||||||
<TVItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[router],
|
[router, showItemActions, posterSizes.poster],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
@@ -328,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 (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
@@ -340,14 +431,13 @@ export default function WatchlistDetailScreen() {
|
|||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: 24,
|
paddingBottom: 24,
|
||||||
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
|
paddingRight: insets.right,
|
||||||
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
|
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
}
|
}
|
||||||
renderItem={Platform.isTV ? renderTVItem : renderItem}
|
renderItem={renderItem}
|
||||||
ItemSeparatorComponent={() => (
|
ItemSeparatorComponent={() => (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useTVBackHandler } from "@/hooks/useTVBackHandler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
@@ -36,6 +37,9 @@ export default function TabLayout() {
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Handle TV back button - prevent app exit when at root
|
||||||
|
useTVBackHandler();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getPlaystateApi,
|
getPlaystateApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { File } from "expo-file-system";
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -45,10 +46,11 @@ import {
|
|||||||
} from "@/modules";
|
} from "@/modules";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
@@ -57,8 +59,8 @@ import {
|
|||||||
getMpvSubtitleId,
|
getMpvSubtitleId,
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
@@ -105,6 +107,9 @@ export default function page() {
|
|||||||
// when data updates, only when the provider initializes
|
// when data updates, only when the provider initializes
|
||||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||||
|
|
||||||
|
// Inactivity timer controls (TV only)
|
||||||
|
const { pauseInactivityTimer, resumeInactivityTimer } = useInactivity();
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -228,7 +233,12 @@ export default function page() {
|
|||||||
setDownloadedItem(data);
|
setDownloadedItem(data);
|
||||||
}
|
}
|
||||||
} else {
|
} 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,
|
itemId,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
@@ -262,6 +272,7 @@ export default function page() {
|
|||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
requiredHttpHeaders?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [stream, setStream] = useState<Stream | null>(null);
|
const [stream, setStream] = useState<Stream | null>(null);
|
||||||
@@ -324,7 +335,7 @@ export default function page() {
|
|||||||
deviceProfile: generateDeviceProfile(),
|
deviceProfile: generateDeviceProfile(),
|
||||||
});
|
});
|
||||||
if (!res) return null;
|
if (!res) return null;
|
||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
if (!sessionId || !mediaSource || !url) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -333,7 +344,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
result = { mediaSource, sessionId, url };
|
result = { mediaSource, sessionId, url, requiredHttpHeaders };
|
||||||
}
|
}
|
||||||
setStream(result);
|
setStream(result);
|
||||||
setStreamStatus({ isLoading: false, isError: false });
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
@@ -420,7 +431,9 @@ export default function page() {
|
|||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [videoRef, reportPlaybackStopped, progress]);
|
// Resume inactivity timer when leaving player (TV only)
|
||||||
|
resumeInactivityTimer();
|
||||||
|
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
@@ -587,6 +600,13 @@ export default function page() {
|
|||||||
autoplay: true,
|
autoplay: true,
|
||||||
initialSubtitleId,
|
initialSubtitleId,
|
||||||
initialAudioId,
|
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
|
// Add external subtitles only for online playback
|
||||||
@@ -594,17 +614,32 @@ export default function page() {
|
|||||||
source.externalSubtitles = externalSubs;
|
source.externalSubtitles = externalSubs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add auth headers only for online streaming (not for local file:// URLs)
|
// Add headers for online streaming (not for local file:// URLs)
|
||||||
if (!offline && api?.accessToken) {
|
if (!offline) {
|
||||||
source.headers = {
|
const headers: Record<string, string> = {};
|
||||||
Authorization: `MediaBrowser Token="${api.accessToken}"`,
|
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;
|
return source;
|
||||||
}, [
|
}, [
|
||||||
stream?.url,
|
stream?.url,
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
|
stream?.requiredHttpHeaders,
|
||||||
item?.UserData?.PlaybackPositionTicks,
|
item?.UserData?.PlaybackPositionTicks,
|
||||||
playbackPositionFromUrl,
|
playbackPositionFromUrl,
|
||||||
api?.basePath,
|
api?.basePath,
|
||||||
@@ -612,6 +647,10 @@ export default function page() {
|
|||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
|
settings.mpvCacheEnabled,
|
||||||
|
settings.mpvCacheSeconds,
|
||||||
|
settings.mpvDemuxerMaxBytes,
|
||||||
|
settings.mpvDemuxerMaxBackBytes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
@@ -702,6 +741,8 @@ export default function page() {
|
|||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
setHasPlaybackStarted(true);
|
setHasPlaybackStarted(true);
|
||||||
|
// Pause inactivity timer during playback (TV only)
|
||||||
|
pauseInactivityTimer();
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
@@ -713,6 +754,8 @@ export default function page() {
|
|||||||
|
|
||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
// Resume inactivity timer when paused (TV only)
|
||||||
|
resumeInactivityTimer();
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
@@ -726,7 +769,13 @@ export default function page() {
|
|||||||
setIsBuffering(isLoading);
|
setIsBuffering(isLoading);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackManager, item?.Id, progress],
|
[
|
||||||
|
playbackManager,
|
||||||
|
item?.Id,
|
||||||
|
progress,
|
||||||
|
pauseInactivityTimer,
|
||||||
|
resumeInactivityTimer,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** PiP handler for MPV */
|
/** PiP handler for MPV */
|
||||||
@@ -1028,14 +1077,27 @@ export default function page() {
|
|||||||
if (settings.mpvSubtitleAlignY !== undefined) {
|
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||||
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
|
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
|
||||||
}
|
}
|
||||||
if (settings.mpvSubtitleFontSize !== undefined) {
|
// Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation)
|
||||||
await videoRef.current?.setSubtitleFontSize?.(
|
// mpv uses #RRGGBBAA format (alpha last, same as CSS)
|
||||||
settings.mpvSubtitleFontSize,
|
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}`,
|
||||||
);
|
);
|
||||||
}
|
// Force override ASS subtitle styles so background shows on styled subtitles
|
||||||
// Apply subtitle size from general settings
|
await videoRef.current?.setSubtitleAssOverride?.("force");
|
||||||
if (settings.subtitleSize) {
|
} else {
|
||||||
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
|
// 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");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1056,6 +1118,28 @@ export default function page() {
|
|||||||
applyInitialPlaybackSpeed();
|
applyInitialPlaybackSpeed();
|
||||||
}, [isVideoLoaded, initialPlaybackSpeed]);
|
}, [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
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
return (
|
return (
|
||||||
@@ -1180,6 +1264,7 @@ export default function page() {
|
|||||||
getTechnicalInfo={getTechnicalInfo}
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
playMethod={playMethod}
|
playMethod={playMethod}
|
||||||
transcodeReasons={transcodeReasons}
|
transcodeReasons={transcodeReasons}
|
||||||
|
downloadedFiles={downloadedFiles}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Controls
|
<Controls
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRo
|
|||||||
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
||||||
import { TVButton, TVOptionSelector } from "@/components/tv";
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||||
@@ -30,6 +30,7 @@ import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/
|
|||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
export default function TVRequestModalPage() {
|
export default function TVRequestModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modalState = useAtomValue(tvRequestModalAtom);
|
const modalState = useAtomValue(tvRequestModalAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -336,8 +337,12 @@ export default function TVRequestModalPage() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={styles.heading}>{t("jellyseerr.advanced")}</Text>
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
<Text style={styles.subtitle}>{modalState.title}</Text>
|
{t("jellyseerr.advanced")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{isDataLoaded && isReady ? (
|
{isDataLoaded && isReady ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -390,7 +395,12 @@ export default function TVRequestModalPage() {
|
|||||||
color='#FFFFFF'
|
color='#FFFFFF'
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.buttonText}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.buttonText,
|
||||||
|
{ fontSize: typography.callout },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("jellyseerr.request_button")}
|
{t("jellyseerr.request_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TVButton>
|
</TVButton>
|
||||||
@@ -451,13 +461,11 @@ const styles = StyleSheet.create({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
heading: {
|
heading: {
|
||||||
fontSize: TVTypography.heading,
|
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
},
|
},
|
||||||
@@ -482,7 +490,6 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVButton } from "@/components/tv";
|
import { TVButton } from "@/components/tv";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||||
@@ -162,6 +162,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function TVSeasonSelectModalPage() {
|
export default function TVSeasonSelectModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -305,8 +306,12 @@ export default function TVSeasonSelectModalPage() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={styles.heading}>{t("jellyseerr.select_seasons")}</Text>
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
<Text style={styles.subtitle}>{modalState.title}</Text>
|
{t("jellyseerr.select_seasons")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{/* Season cards horizontal scroll */}
|
{/* Season cards horizontal scroll */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -343,7 +348,9 @@ export default function TVSeasonSelectModalPage() {
|
|||||||
color='#FFFFFF'
|
color='#FFFFFF'
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.buttonText}>
|
<Text
|
||||||
|
style={[styles.buttonText, { fontSize: typography.callout }]}
|
||||||
|
>
|
||||||
{t("jellyseerr.request_selected")}
|
{t("jellyseerr.request_selected")}
|
||||||
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -377,13 +384,11 @@ const styles = StyleSheet.create({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
heading: {
|
heading: {
|
||||||
fontSize: TVTypography.heading,
|
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
},
|
},
|
||||||
@@ -413,7 +418,6 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
seasonTitle: {
|
seasonTitle: {
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
@@ -436,7 +440,6 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVCancelButton, TVOptionCard } from "@/components/tv";
|
import { TVCancelButton, TVOptionCard } from "@/components/tv";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
export default function TVSeriesSeasonModalPage() {
|
export default function TVSeriesSeasonModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
|
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -103,7 +104,9 @@ export default function TVSeriesSeasonModalPage() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>{t("item_card.select_season")}</Text>
|
<Text style={[styles.title, { fontSize: typography.callout }]}>
|
||||||
|
{t("item_card.select_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -164,7 +167,6 @@ const styles = StyleSheet.create({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
@@ -659,8 +659,30 @@ export default function TVSubtitleModal() {
|
|||||||
|
|
||||||
// Do NOT close modal - user can see and select the new track
|
// Do NOT close modal - user can see and select the new track
|
||||||
} else if (downloadResult.type === "local" && downloadResult.path) {
|
} else if (downloadResult.type === "local" && downloadResult.path) {
|
||||||
|
// Notify parent that a local subtitle was downloaded
|
||||||
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
|
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
|
||||||
handleClose(); // Only close for local downloads
|
|
||||||
|
// Check if component is still mounted after callback
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
// Refresh tracks to include the newly downloaded subtitle
|
||||||
|
if (modalState?.refreshSubtitleTracks) {
|
||||||
|
const newTracks = await modalState.refreshSubtitleTracks();
|
||||||
|
|
||||||
|
// Check if component is still mounted after fetching tracks
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
// Update atom with new tracks
|
||||||
|
store.set(tvSubtitleModalAtom, {
|
||||||
|
...modalState,
|
||||||
|
subtitleTracks: newTracks,
|
||||||
|
});
|
||||||
|
// Switch to tracks tab to show the new subtitle
|
||||||
|
setActiveTab("tracks");
|
||||||
|
} else {
|
||||||
|
// No refreshSubtitleTracks available (e.g., from player), just close
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to download subtitle:", error);
|
console.error("Failed to download subtitle:", error);
|
||||||
@@ -685,13 +707,17 @@ export default function TVSubtitleModal() {
|
|||||||
value: -1,
|
value: -1,
|
||||||
selected: currentSubtitleIndex === -1,
|
selected: currentSubtitleIndex === -1,
|
||||||
setTrack: () => modalState?.onDisableSubtitles?.(),
|
setTrack: () => modalState?.onDisableSubtitles?.(),
|
||||||
|
isLocal: false,
|
||||||
};
|
};
|
||||||
const options = subtitleTracks.map((track: Track) => ({
|
const options = subtitleTracks.map((track: Track) => ({
|
||||||
label: track.name,
|
label: track.name,
|
||||||
sublabel: undefined as string | undefined,
|
sublabel: track.isLocal
|
||||||
|
? t("player.downloaded") || "Downloaded"
|
||||||
|
: (undefined as string | undefined),
|
||||||
value: track.index,
|
value: track.index,
|
||||||
selected: track.index === currentSubtitleIndex,
|
selected: track.index === currentSubtitleIndex,
|
||||||
setTrack: track.setTrack,
|
setTrack: track.setTrack,
|
||||||
|
isLocal: track.isLocal ?? false,
|
||||||
}));
|
}));
|
||||||
return [noneOption, ...options];
|
return [noneOption, ...options];
|
||||||
}, [subtitleTracks, currentSubtitleIndex, t, modalState]);
|
}, [subtitleTracks, currentSubtitleIndex, t, modalState]);
|
||||||
@@ -905,8 +931,8 @@ export default function TVSubtitleModal() {
|
|||||||
<View style={styles.settingRow}>
|
<View style={styles.settingRow}>
|
||||||
<TVStepperControl
|
<TVStepperControl
|
||||||
value={settings.mpvSubtitleScale ?? 1.0}
|
value={settings.mpvSubtitleScale ?? 1.0}
|
||||||
min={0.5}
|
min={0.1}
|
||||||
max={2.0}
|
max={3.0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
onChange={(newValue) => {
|
onChange={(newValue) => {
|
||||||
|
|||||||
174
app/(auth)/tv-user-switch-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,
|
||||||
|
},
|
||||||
|
});
|
||||||
279
app/_layout.tsx
279
app/_layout.tsx
@@ -10,10 +10,11 @@ import * as BackgroundTask from "expo-background-task";
|
|||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||||
|
import { InactivityProvider } from "@/providers/InactivityProvider";
|
||||||
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
|
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
|
||||||
import {
|
import {
|
||||||
apiAtom,
|
apiAtom,
|
||||||
@@ -55,15 +56,31 @@ import * as TaskManager from "expo-task-manager";
|
|||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { I18nextProvider } from "react-i18next";
|
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 { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
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 useRouter from "@/hooks/useAppRouter";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { store as jotaiStore, store } from "@/utils/store";
|
import { store as jotaiStore, store } from "@/utils/store";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
|
import {
|
||||||
|
configureReanimatedLogger,
|
||||||
|
ReanimatedLogLevel,
|
||||||
|
} from "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
|
// Disable strict mode warnings for reading shared values during render
|
||||||
|
configureReanimatedLogger({
|
||||||
|
level: ReanimatedLogLevel.warn,
|
||||||
|
strict: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
@@ -233,6 +250,11 @@ function Layout() {
|
|||||||
const _segments = useSegments();
|
const _segments = useSegments();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Enable TV menu key interception so React Native handles it instead of tvOS
|
||||||
|
useEffect(() => {
|
||||||
|
enableTVMenuKeyInterception();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
|
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
|
||||||
@@ -253,22 +275,19 @@ function Layout() {
|
|||||||
deviceId: getOrSetDeviceId(),
|
deviceId: getOrSetDeviceId(),
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
})
|
})
|
||||||
.then((_) => console.log("Posted expo push token"))
|
|
||||||
.catch((_) =>
|
.catch((_) =>
|
||||||
writeErrorLog("Failed to push expo push token to plugin"),
|
writeErrorLog("Failed to push expo push token to plugin"),
|
||||||
);
|
);
|
||||||
} else console.log("No token available");
|
}
|
||||||
}, [api, expoPushToken, user]);
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
const registerNotifications = useCallback(async () => {
|
const registerNotifications = useCallback(async () => {
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
console.log("Setting android notification channel 'default'");
|
|
||||||
await Notifications?.setNotificationChannelAsync("default", {
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
name: "default",
|
name: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create dedicated channel for download notifications
|
// Create dedicated channel for download notifications
|
||||||
console.log("Setting android notification channel 'downloads'");
|
|
||||||
await Notifications?.setNotificationChannelAsync("downloads", {
|
await Notifications?.setNotificationChannelAsync("downloads", {
|
||||||
name: "Downloads",
|
name: "Downloads",
|
||||||
importance: Notifications.AndroidImportance.DEFAULT,
|
importance: Notifications.AndroidImportance.DEFAULT,
|
||||||
@@ -383,119 +402,145 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<ServerUrlProvider>
|
<InactivityProvider>
|
||||||
<NetworkStatusProvider>
|
<ServerUrlProvider>
|
||||||
<PlaySettingsProvider>
|
<NetworkStatusProvider>
|
||||||
<LogProvider>
|
<PlaySettingsProvider>
|
||||||
<WebSocketProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<MusicPlayerProvider>
|
<DownloadProvider>
|
||||||
<GlobalModalProvider>
|
<MusicPlayerProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<IntroSheetProvider>
|
<BottomSheetModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<IntroSheetProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<SystemBars style='light' hidden={false} />
|
||||||
<Stack.Screen
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
name='(auth)/(tabs)'
|
<Stack.Screen
|
||||||
options={{
|
name='(auth)/(tabs)'
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
header: () => null,
|
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
|
{!Platform.isTV && <GlobalModal />}
|
||||||
name='(auth)/player'
|
</ThemeProvider>
|
||||||
options={{
|
</IntroSheetProvider>
|
||||||
headerShown: false,
|
</BottomSheetModalProvider>
|
||||||
title: "",
|
</GlobalModalProvider>
|
||||||
header: () => null,
|
</MusicPlayerProvider>
|
||||||
}}
|
</DownloadProvider>
|
||||||
/>
|
</WebSocketProvider>
|
||||||
<Stack.Screen
|
</LogProvider>
|
||||||
name='(auth)/now-playing'
|
</PlaySettingsProvider>
|
||||||
options={{
|
</NetworkStatusProvider>
|
||||||
headerShown: false,
|
</ServerUrlProvider>
|
||||||
presentation: "modal",
|
</InactivityProvider>
|
||||||
gestureEnabled: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-option-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-subtitle-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-request-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-season-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-series-season-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
{!Platform.isTV && <GlobalModal />}
|
|
||||||
</ThemeProvider>
|
|
||||||
</IntroSheetProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</GlobalModalProvider>
|
|
||||||
</MusicPlayerProvider>
|
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
|
||||||
</LogProvider>
|
|
||||||
</PlaySettingsProvider>
|
|
||||||
</NetworkStatusProvider>
|
|
||||||
</ServerUrlProvider>
|
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
251
app/tv-account-action-modal.tsx
Normal file
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
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
bun.lock
113
bun.lock
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
"expo-application": "~7.0.8",
|
"expo-application": "~7.0.8",
|
||||||
"expo-asset": "~12.0.12",
|
"expo-asset": "~12.0.12",
|
||||||
|
"expo-av": "^16.0.8",
|
||||||
"expo-background-task": "~1.0.10",
|
"expo-background-task": "~1.0.10",
|
||||||
"expo-blur": "~15.0.8",
|
"expo-blur": "~15.0.8",
|
||||||
"expo-brightness": "~14.0.8",
|
"expo-brightness": "~14.0.8",
|
||||||
@@ -77,7 +79,6 @@
|
|||||||
"react-native-pager-view": "^6.9.1",
|
"react-native-pager-view": "^6.9.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-reanimated-carousel": "4.0.3",
|
"react-native-reanimated-carousel": "4.0.3",
|
||||||
"react-native-responsive-sizes": "^2.1.0",
|
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.18.0",
|
"react-native-screens": "~4.18.0",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
@@ -562,8 +563,6 @@
|
|||||||
|
|
||||||
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
|
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
|
||||||
|
|
||||||
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.79.7", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-CPJ995n1WIyi7KeLj+/aeFCe6MWQrRRXfMvBnc7XP4noSa4WEJfH8Zcvl/iWYVxrQdIaInadoiYLakeSflz5jg=="],
|
|
||||||
|
|
||||||
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
|
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
|
||||||
|
|
||||||
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
|
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
|
||||||
@@ -804,10 +803,6 @@
|
|||||||
|
|
||||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
"caller-callsite": ["caller-callsite@2.0.0", "", { "dependencies": { "callsites": "^2.0.0" } }, "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ=="],
|
|
||||||
|
|
||||||
"caller-path": ["caller-path@2.0.0", "", { "dependencies": { "caller-callsite": "^2.0.0" } }, "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A=="],
|
|
||||||
|
|
||||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||||
@@ -1014,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-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-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=="],
|
"expo-blur": ["expo-blur@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w=="],
|
||||||
@@ -1246,8 +1243,6 @@
|
|||||||
|
|
||||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
|
|
||||||
"is-directory": ["is-directory@0.3.1", "", {}, "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw=="],
|
|
||||||
|
|
||||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
@@ -1326,8 +1321,6 @@
|
|||||||
|
|
||||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
"json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="],
|
|
||||||
|
|
||||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||||
|
|
||||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
@@ -1696,8 +1689,6 @@
|
|||||||
|
|
||||||
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
|
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
|
||||||
|
|
||||||
"react-native-responsive-sizes": ["react-native-responsive-sizes@2.1.0", "", { "dependencies": { "react-native": "^0.79.2" } }, "sha512-uxWi0IDj8CBGRh6KJyQ2RagWmLTWPWF5sDnVpM4jt/khwhEdaUeGa/q9rHcVHbb4o+oo1Zei9P3zIwbFc1UGcw=="],
|
|
||||||
|
|
||||||
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
|
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
|
||||||
|
|
||||||
"react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="],
|
"react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="],
|
||||||
@@ -2292,8 +2283,6 @@
|
|||||||
|
|
||||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
"caller-callsite/callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="],
|
|
||||||
|
|
||||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
@@ -2446,8 +2435,6 @@
|
|||||||
|
|
||||||
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native": ["react-native@0.79.7", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.79.7", "@react-native/codegen": "0.79.7", "@react-native/community-cli-plugin": "0.79.7", "@react-native/gradle-plugin": "0.79.7", "@react-native/js-polyfills": "0.79.7", "@react-native/normalize-colors": "0.79.7", "@react-native/virtualized-lists": "0.79.7", "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.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "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.82.0", "metro-source-map": "^0.82.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.25.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.0.0", "react": "^19.0.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-7B2FJt/P+qulrkjWNttofiQjpZ5czSnL00kr6kQ9GpiykF/agX6Z2GVX6e5ggpQq2jqtyLvRtHIiUnKPYM77+w=="],
|
|
||||||
|
|
||||||
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
|
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
|
||||||
|
|
||||||
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
|
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
|
||||||
@@ -2994,30 +2981,6 @@
|
|||||||
|
|
||||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/assets-registry": ["@react-native/assets-registry@0.79.7", "", {}, "sha512-YeOXq8H5JZQbeIcAtHxmboDt02QG8ej8Z4SFVNh5UjaSb/0X1/v5/DhwNb4dfpIsQ5lFy75jeoSmUVp8qEKu9g=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/codegen": ["@react-native/codegen@0.79.7", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.25.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-uOjsqpLccl0+8iHPBmrkFrWwK0ctW28M83Ln2z43HRNubkxk5Nxd3DoyphFPL/BwTG79Ixu+BqpCS7b9mtizpw=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.79.7", "", { "dependencies": { "@react-native/dev-middleware": "0.79.7", "chalk": "^4.0.0", "debug": "^2.2.0", "invariant": "^2.2.4", "metro": "^0.82.0", "metro-config": "^0.82.0", "metro-core": "^0.82.0", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*" }, "optionalPeers": ["@react-native-community/cli"] }, "sha512-UQADqWfnKfEGMIyOa1zI8TMAOOLDdQ3h2FTCG8bp+MFGLAaJowaa+4GGb71A26fbg06/qnGy/Kr0Mv41IFGZnQ=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.79.7", "", {}, "sha512-vQqVthSs2EGqzV4KI0uFr/B4hUVXhVM86ekYL8iZCXzO6bewZa7lEUNGieijY0jc0a/mBJ6KZDzMtcUoS5vFRA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/js-polyfills": ["@react-native/js-polyfills@0.79.7", "", {}, "sha512-Djgvfz6AOa8ZEWyv+KA/UnP+ZruM+clCauFTR6NeRyD8YELvXGt+6A231SwpNdRkM7aTDMv0cM0NUbAMEPy+1A=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/normalize-colors": ["@react-native/normalize-colors@0.79.7", "", {}, "sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.25.1", "", { "dependencies": { "hermes-parser": "0.25.1" } }, "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/metro-runtime": ["metro-runtime@0.82.5", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-rQZDoCUf7k4Broyw3Ixxlq5ieIPiR1ULONdpcYpbJQ6yQ5GGEyYjtkztGD+OhHlw81LCR2SUAoPvtTus2WDK5g=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/metro-source-map": ["metro-source-map@0.82.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.82.5", "nullthrows": "^1.1.1", "ob1": "0.82.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-wH+awTOQJVkbhn2SKyaw+0cd+RVSCZ3sHVgyqJFQXIee/yLs3dZqKjjeKKhhVeudgjXo7aE/vSu/zVfcQEcUfw=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
|
||||||
|
|
||||||
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||||
|
|
||||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
@@ -3202,26 +3165,6 @@
|
|||||||
|
|
||||||
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.79.7", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.79.7", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-KHGPa7xwnKKWrzMnV1cHc8J56co4tFevmRvbjEbUCqkGS0s/l8ZxAGMR222/6YxZV3Eg1J3ywKQ8nHzTsTz5jw=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro": ["metro@0.82.5", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-config": "0.82.5", "metro-core": "0.82.5", "metro-file-map": "0.82.5", "metro-resolver": "0.82.5", "metro-runtime": "0.82.5", "metro-source-map": "0.82.5", "metro-symbolicate": "0.82.5", "metro-transform-plugins": "0.82.5", "metro-transform-worker": "0.82.5", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8oAXxL7do8QckID/WZEKaIFuQJFUTLzfVcC48ghkHhNK2RGuQq8Xvf4AVd+TUA0SZtX0q8TGNXZ/eba1ckeGCg=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config": ["metro-config@0.82.5", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.82.5", "metro-cache": "0.82.5", "metro-core": "0.82.5", "metro-runtime": "0.82.5" } }, "sha512-/r83VqE55l0WsBf8IhNmc/3z71y2zIPe5kRSuqA5tY/SL/ULzlHUJEMd1szztd0G45JozLwjvrhAzhDPJ/Qo/g=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core": ["metro-core@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.82.5" } }, "sha512-OJL18VbSw2RgtBm1f2P3J5kb892LCVJqMvslXxuxjAPex8OH7Eb8RBfgEo7VZSjgb/LOf4jhC4UFk5l5tAOHHA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/metro-source-map/@babel/traverse--for-generate-function-map": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/metro-source-map/ob1": ["ob1@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ=="],
|
|
||||||
|
|
||||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
@@ -3258,42 +3201,6 @@
|
|||||||
|
|
||||||
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.79.7", "", {}, "sha512-91JVlhR6hDuJXcWTpCwcdEPlUQf+TckNG8BYfR4UkUOaZ87XahJv4EyWBeyfd8lwB/mh6nDJqbR6UiXwt5kbog=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.29.1", "nullthrows": "^1.1.1" } }, "sha512-W/scFDnwJXSccJYnOFdGiYr9srhbHPdxX9TvvACOFsIXdLilh3XuxQl/wXW6jEJfgIb0jTvoTlwwrqvuwymr6Q=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-qpVmPbDJuRLrT4kcGlUouyqLGssJnbTllVtvIgXfR7ZuzMKf0mGS+8WzcqzNK8+kCyakombQWR0uDd8qhWGJcA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.82.5", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-vpMDxkGIB+MTN8Af5hvSAanc6zXQipsAUO+XUx3PCQieKUfLwdoa8qaZ1WAQYRpaU+CJ8vhBcxtzzo3d9IsCIQ=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-57Bqf3rgq9nPqLrT2d9kf/2WVieTFqsQ6qWHpEng5naIUtc/Iiw9+0bfLLWSAw0GH40iJ4yMjFcFJDtNSYynMA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.82.5", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-minify-terser": "0.82.5", "metro-source-map": "0.82.5", "metro-transform-plugins": "0.82.5", "nullthrows": "^1.1.1" } }, "sha512-mx0grhAX7xe+XUQH6qoHHlWedI8fhSpDGsfga7CpkO9Lk9W+aPitNtJWNGrW8PfjKEWbT9Uz9O50dkI8bJqigw=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig": ["cosmiconfig@5.2.1", "", { "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", "js-yaml": "^3.13.1", "parse-json": "^4.0.0" } }, "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
|
||||||
|
|
||||||
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||||
|
|
||||||
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
|
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
|
||||||
@@ -3306,18 +3213,6 @@
|
|||||||
|
|
||||||
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh": ["import-fresh@2.0.0", "", { "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" } }, "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-v6Nx7A4We6PqPu/ta1oGTqJ4Usz0P7c+3XNeBxW9kp8zayS3lHUKR0sY0wsCHInxZlNAEICx791x+uXytFUuwg=="],
|
|
||||||
|
|
||||||
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh/resolve-from": ["resolve-from@3.0.0", "", {}, "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw=="],
|
|
||||||
|
|
||||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -16,6 +16,8 @@ export const Badge: React.FC<Props> = ({
|
|||||||
variant = "purple",
|
variant = "purple",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||||
@@ -69,7 +71,7 @@ export const Badge: React.FC<Props> = ({
|
|||||||
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
|
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { fontSize, size } from "react-native-responsive-sizes";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
@@ -133,7 +132,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
shadowColor: color === "black" ? "#ffffff" : "#a855f7",
|
shadowColor: "#ffffff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.5 : 0,
|
shadowOpacity: focused ? 0.5 : 0,
|
||||||
shadowRadius: focused ? 10 : 0,
|
shadowRadius: focused ? 10 : 0,
|
||||||
@@ -141,15 +140,11 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl items-center justify-center
|
className={`rounded-2xl py-5 items-center justify-center
|
||||||
${colorClasses}
|
${colorClasses}
|
||||||
${className}`}
|
${className}`}
|
||||||
style={{ paddingVertical: size(20) }}
|
|
||||||
>
|
>
|
||||||
<Text
|
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||||
className={`${textColorClass} font-bold`}
|
|
||||||
style={{ fontSize: fontSize(20) }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { ProgressBar } from "./common/ProgressBar";
|
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
|
||||||
|
|
||||||
export const TV_LANDSCAPE_WIDTH = 340;
|
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
|
||||||
item: BaseItemDto;
|
|
||||||
useEpisodePoster?: boolean;
|
|
||||||
size?: "small" | "normal";
|
|
||||||
showPlayButton?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|
||||||
item,
|
|
||||||
useEpisodePoster = false,
|
|
||||||
// TV version uses fixed width, size prop kept for API compatibility
|
|
||||||
size: _size = "normal",
|
|
||||||
showPlayButton = false,
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
|
|
||||||
const url = useMemo(() => {
|
|
||||||
if (!api) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (item.Type === "Episode" && useEpisodePoster) {
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
|
||||||
}
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`;
|
|
||||||
}
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
|
||||||
}
|
|
||||||
if (item.Type === "Movie") {
|
|
||||||
if (item.ImageTags?.Thumb) {
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
|
||||||
}
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
|
||||||
}
|
|
||||||
if (item.Type === "Program") {
|
|
||||||
if (item.ImageTags?.Thumb) {
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
|
||||||
}
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.ImageTags?.Thumb) {
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
|
||||||
}, [api, item, useEpisodePoster]);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: TV_LANDSCAPE_WIDTH,
|
|
||||||
aspectRatio: 16 / 9,
|
|
||||||
borderRadius: 24,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: TV_LANDSCAPE_WIDTH,
|
|
||||||
aspectRatio: 16 / 9,
|
|
||||||
borderRadius: 24,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
key={item.Id}
|
|
||||||
id={item.Id}
|
|
||||||
source={{
|
|
||||||
uri: url,
|
|
||||||
}}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit='cover'
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{showPlayButton && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-circle' size={56} color='white' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<WatchedIndicator item={item} />
|
|
||||||
<ProgressBar item={item} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContinueWatchingPoster;
|
|
||||||
@@ -73,12 +73,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
|
const playSettingsOptions = useMemo(
|
||||||
|
() => ({ applyLanguagePreferences: true }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(items[0], settings);
|
} = useDefaultPlaySettings(items[0], settings, playSettingsOptions);
|
||||||
|
|
||||||
const userCanDownload = useMemo(
|
const userCanDownload = useMemo(
|
||||||
() => user?.Policy?.EnableContentDownloading,
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type ViewProps,
|
type ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
@@ -25,6 +25,9 @@ export const Tag: React.FC<
|
|||||||
textStyle?: StyleProp<TextStyle>;
|
textStyle?: StyleProp<TextStyle>;
|
||||||
} & ViewProps
|
} & ViewProps
|
||||||
> = ({ text, textClass, textStyle, ...props }) => {
|
> = ({ text, textClass, textStyle, ...props }) => {
|
||||||
|
// Hook must be called at the top level, before any conditional returns
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -60,7 +63,7 @@ export const Tag: React.FC<
|
|||||||
backgroundColor: "rgba(0,0,0,0.3)",
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: TVTypography.callout, color: "#E5E7EB" }}>
|
<Text style={{ fontSize: typography.callout, color: "#E5E7EB" }}>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -75,12 +75,20 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
// Use itemWithSources for play settings since it has MediaSources data
|
// Use itemWithSources for play settings since it has MediaSources data
|
||||||
|
const playSettingsOptions = useMemo(
|
||||||
|
() => ({ applyLanguagePreferences: true }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
} = useDefaultPlaySettings(
|
||||||
|
itemWithSources ?? item,
|
||||||
|
settings,
|
||||||
|
playSettingsOptions,
|
||||||
|
);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
MediaStream,
|
MediaStream,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
|
import { File } from "expo-file-system";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -17,12 +18,14 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
|
import { Alert, Dimensions, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
|
||||||
import {
|
import {
|
||||||
TVBackdrop,
|
TVBackdrop,
|
||||||
TVButton,
|
TVButton,
|
||||||
@@ -31,25 +34,29 @@ import {
|
|||||||
TVFavoriteButton,
|
TVFavoriteButton,
|
||||||
TVMetadataBadges,
|
TVMetadataBadges,
|
||||||
TVOptionButton,
|
TVOptionButton,
|
||||||
|
TVPlayedButton,
|
||||||
TVProgressBar,
|
TVProgressBar,
|
||||||
TVRefreshButton,
|
TVRefreshButton,
|
||||||
TVSeriesNavigation,
|
TVSeriesNavigation,
|
||||||
TVTechnicalDetails,
|
TVTechnicalDetails,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import type { Track } from "@/components/video-player/controls/types";
|
import type { Track } from "@/components/video-player/controls/types";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||||
|
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
@@ -69,27 +76,67 @@ interface ItemContentTVProps {
|
|||||||
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
|
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
|
||||||
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||||
({ item, itemWithSources }) => {
|
({ item, itemWithSources }) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [_user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const _itemColors = useImageColorsReturn({ item });
|
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<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
|
// Enable language preference application for TV
|
||||||
|
const playSettingsOptions = useMemo(
|
||||||
|
() => ({ applyLanguagePreferences: true }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
} = useDefaultPlaySettings(
|
||||||
|
itemWithSources ?? item,
|
||||||
|
settings,
|
||||||
|
playSettingsOptions,
|
||||||
|
);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
@@ -111,21 +158,59 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
defaultMediaSource,
|
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 = () => {
|
const handlePlay = () => {
|
||||||
if (!item || !selectedOptions) return;
|
if (!item || !selectedOptions) return;
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const hasPlaybackProgress =
|
||||||
itemId: item.Id!,
|
(item.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
offline: isOffline ? "true" : "false",
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
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
|
// TV Option Modal hook for quality, audio, media source selectors
|
||||||
@@ -135,12 +220,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
const { showSubtitleModal } = useTVSubtitleModal();
|
const { showSubtitleModal } = useTVSubtitleModal();
|
||||||
|
|
||||||
// State for first actor card ref (used for focus guide)
|
// State for first actor card ref (used for focus guide)
|
||||||
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
const [_firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// State for last option button ref (used for upward focus guide from cast)
|
|
||||||
const [lastOptionButtonRef, setLastOptionButtonRef] = useState<View | null>(
|
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -165,9 +245,16 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
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)
|
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||||
|
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||||
return subtitleStreams.map((stream) => ({
|
const tracks: Track[] = subtitleStreams.map((stream) => ({
|
||||||
name:
|
name:
|
||||||
stream.DisplayTitle ||
|
stream.DisplayTitle ||
|
||||||
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||||
@@ -176,7 +263,37 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [subtitleStreams]);
|
|
||||||
|
// 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
|
// Get available media sources
|
||||||
const mediaSources = useMemo(() => {
|
const mediaSources = useMemo(() => {
|
||||||
@@ -268,6 +385,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
}
|
}
|
||||||
}, [item?.Id, queryClient]);
|
}, [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
|
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
|
||||||
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
|
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
|
||||||
if (!api || !item?.Id) return [];
|
if (!api || !item?.Id) return [];
|
||||||
@@ -295,7 +418,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
// Convert to Track[] with setTrack callbacks
|
// Convert to Track[] with setTrack callbacks
|
||||||
return streams.map((stream) => ({
|
const tracks: Track[] = streams.map((stream) => ({
|
||||||
name:
|
name:
|
||||||
stream.DisplayTitle ||
|
stream.DisplayTitle ||
|
||||||
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||||
@@ -304,6 +427,30 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to refresh subtitle tracks:", error);
|
console.error("Failed to refresh subtitle tracks:", error);
|
||||||
return [];
|
return [];
|
||||||
@@ -321,13 +468,30 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
const selectedSubtitleLabel = useMemo(() => {
|
const selectedSubtitleLabel = useMemo(() => {
|
||||||
if (selectedOptions?.subtitleIndex === -1)
|
if (selectedOptions?.subtitleIndex === -1)
|
||||||
return t("item_card.subtitles.none");
|
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(
|
const track = subtitleStreams.find(
|
||||||
(t) => t.Index === selectedOptions?.subtitleIndex,
|
(t) => t.Index === selectedOptions?.subtitleIndex,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
||||||
);
|
);
|
||||||
}, [subtitleStreams, selectedOptions?.subtitleIndex, t]);
|
}, [
|
||||||
|
subtitleStreams,
|
||||||
|
subtitleTracksForModal,
|
||||||
|
selectedOptions?.subtitleIndex,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectedMediaSourceLabel = useMemo(() => {
|
const selectedMediaSourceLabel = useMemo(() => {
|
||||||
const source = selectedOptions?.mediaSource;
|
const source = selectedOptions?.mediaSource;
|
||||||
@@ -386,24 +550,17 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
|
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
|
||||||
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
||||||
|
|
||||||
// Determine which option button is the last one (for focus guide targeting)
|
// Episode thumbnail URL - episode's own primary image (16:9 for episodes)
|
||||||
const lastOptionButton = useMemo(() => {
|
const episodeThumbnailUrl = useMemo(() => {
|
||||||
const hasSubtitleOption =
|
if (item?.Type !== "Episode" || !api) return null;
|
||||||
subtitleStreams.length > 0 ||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
selectedOptions?.subtitleIndex !== undefined;
|
}, [api, item]);
|
||||||
const hasAudioOption = audioTracks.length > 0;
|
|
||||||
const hasMediaSourceOption = mediaSources.length > 1;
|
|
||||||
|
|
||||||
if (hasSubtitleOption) return "subtitle";
|
// Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled
|
||||||
if (hasAudioOption) return "audio";
|
const seriesThumbUrl = useMemo(() => {
|
||||||
if (hasMediaSourceOption) return "mediaSource";
|
if (item?.Type !== "Episode" || !item.SeriesId || !api) return null;
|
||||||
return "quality";
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
|
||||||
}, [
|
}, [api, item]);
|
||||||
subtitleStreams.length,
|
|
||||||
selectedOptions?.subtitleIndex,
|
|
||||||
audioTracks.length,
|
|
||||||
mediaSources.length,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Navigation handlers
|
// Navigation handlers
|
||||||
const handleActorPress = useCallback(
|
const handleActorPress = useCallback(
|
||||||
@@ -427,6 +584,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
}
|
}
|
||||||
}, [router, item?.SeriesId, 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;
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -456,36 +621,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
minHeight: SCREEN_HEIGHT * 0.45,
|
minHeight: SCREEN_HEIGHT * 0.45,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side - Poster */}
|
{/* Left side - Content */}
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: SCREEN_WIDTH * 0.22,
|
|
||||||
marginRight: 50,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
aspectRatio: 2 / 3,
|
|
||||||
borderRadius: 16,
|
|
||||||
overflow: "hidden",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 10 },
|
|
||||||
shadowOpacity: 0.5,
|
|
||||||
shadowRadius: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ItemImage
|
|
||||||
variant='Primary'
|
|
||||||
item={item}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Right side - Content */}
|
|
||||||
<View style={{ flex: 1, justifyContent: "center" }}>
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
{/* Logo or Title */}
|
{/* Logo or Title */}
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
@@ -502,7 +638,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.display,
|
fontSize: typography.display,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
@@ -518,7 +654,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.title,
|
fontSize: typography.title,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
}}
|
}}
|
||||||
@@ -527,7 +663,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "white",
|
color: "white",
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
}}
|
}}
|
||||||
@@ -572,7 +708,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
lineHeight: 32,
|
lineHeight: 32,
|
||||||
}}
|
}}
|
||||||
@@ -605,7 +741,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
@@ -616,27 +752,24 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
</Text>
|
</Text>
|
||||||
</TVButton>
|
</TVButton>
|
||||||
<TVFavoriteButton item={item} />
|
<TVFavoriteButton item={item} />
|
||||||
|
<TVPlayedButton item={item} />
|
||||||
<TVRefreshButton itemId={item.Id} />
|
<TVRefreshButton itemId={item.Id} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Playback options */}
|
{/* Playback options */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "column",
|
flexDirection: "row",
|
||||||
alignItems: "flex-start",
|
alignItems: "center",
|
||||||
gap: 10,
|
gap: 12,
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Quality selector */}
|
{/* Quality selector */}
|
||||||
<TVOptionButton
|
<TVOptionButton
|
||||||
ref={
|
|
||||||
lastOptionButton === "quality"
|
|
||||||
? setLastOptionButtonRef
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
label={t("item_card.quality")}
|
label={t("item_card.quality")}
|
||||||
value={selectedQualityLabel}
|
value={selectedQualityLabel}
|
||||||
|
maxWidth={200}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("item_card.quality"),
|
title: t("item_card.quality"),
|
||||||
@@ -649,13 +782,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
{/* Media source selector (only if multiple sources) */}
|
{/* Media source selector (only if multiple sources) */}
|
||||||
{mediaSources.length > 1 && (
|
{mediaSources.length > 1 && (
|
||||||
<TVOptionButton
|
<TVOptionButton
|
||||||
ref={
|
|
||||||
lastOptionButton === "mediaSource"
|
|
||||||
? setLastOptionButtonRef
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
label={t("item_card.video")}
|
label={t("item_card.video")}
|
||||||
value={selectedMediaSourceLabel}
|
value={selectedMediaSourceLabel}
|
||||||
|
maxWidth={280}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("item_card.video"),
|
title: t("item_card.video"),
|
||||||
@@ -669,13 +798,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
{/* Audio selector */}
|
{/* Audio selector */}
|
||||||
{audioTracks.length > 0 && (
|
{audioTracks.length > 0 && (
|
||||||
<TVOptionButton
|
<TVOptionButton
|
||||||
ref={
|
|
||||||
lastOptionButton === "audio"
|
|
||||||
? setLastOptionButtonRef
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
label={t("item_card.audio")}
|
label={t("item_card.audio")}
|
||||||
value={selectedAudioLabel}
|
value={selectedAudioLabel}
|
||||||
|
maxWidth={280}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("item_card.audio"),
|
title: t("item_card.audio"),
|
||||||
@@ -690,13 +815,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
{(subtitleStreams.length > 0 ||
|
{(subtitleStreams.length > 0 ||
|
||||||
selectedOptions?.subtitleIndex !== undefined) && (
|
selectedOptions?.subtitleIndex !== undefined) && (
|
||||||
<TVOptionButton
|
<TVOptionButton
|
||||||
ref={
|
|
||||||
lastOptionButton === "subtitle"
|
|
||||||
? setLastOptionButtonRef
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
label={t("item_card.subtitles.label")}
|
label={t("item_card.subtitles.label")}
|
||||||
value={selectedSubtitleLabel}
|
value={selectedSubtitleLabel}
|
||||||
|
maxWidth={280}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showSubtitleModal({
|
showSubtitleModal({
|
||||||
item,
|
item,
|
||||||
@@ -707,6 +828,8 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
onDisableSubtitles: () => handleSubtitleChange(-1),
|
onDisableSubtitles: () => handleSubtitleChange(-1),
|
||||||
onServerSubtitleDownloaded:
|
onServerSubtitleDownloaded:
|
||||||
handleServerSubtitleDownloaded,
|
handleServerSubtitleDownloaded,
|
||||||
|
onLocalSubtitleDownloaded:
|
||||||
|
handleLocalSubtitleDownloaded,
|
||||||
refreshSubtitleTracks,
|
refreshSubtitleTracks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -714,14 +837,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Focus guide to direct navigation from options to cast list */}
|
|
||||||
{fullCast.length > 0 && firstActorCardRef && (
|
|
||||||
<TVFocusGuideView
|
|
||||||
destinations={[firstActorCardRef]}
|
|
||||||
style={{ height: 1, width: "100%" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress bar (if partially watched) */}
|
{/* Progress bar (if partially watched) */}
|
||||||
{hasProgress && item.RunTimeTicks != null && (
|
{hasProgress && item.RunTimeTicks != null && (
|
||||||
<TVProgressBar
|
<TVProgressBar
|
||||||
@@ -733,11 +848,99 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
{/* Additional info section */}
|
{/* Additional info section */}
|
||||||
<View style={{ marginTop: 40 }}>
|
<View style={{ marginTop: 40 }}>
|
||||||
{/* Cast & Crew (text version) */}
|
{/* 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
|
<TVCastCrewText
|
||||||
director={director}
|
director={director}
|
||||||
cast={cast}
|
cast={cast}
|
||||||
@@ -751,26 +954,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
mediaStreams={selectedOptions.mediaSource.MediaStreams}
|
mediaStreams={selectedOptions.mediaSource.MediaStreams}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
|
||||||
{showVisualCast && (
|
|
||||||
<TVCastSection
|
|
||||||
cast={fullCast}
|
|
||||||
apiBasePath={api?.basePath}
|
|
||||||
onActorPress={handleActorPress}
|
|
||||||
firstActorRefSetter={setFirstActorCardRef}
|
|
||||||
upwardFocusDestination={lastOptionButtonRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* From this Series - Episode only */}
|
|
||||||
<TVSeriesNavigation
|
|
||||||
item={item}
|
|
||||||
seriesImageUrl={seriesImageUrl}
|
|
||||||
seasonImageUrl={seasonImageUrl}
|
|
||||||
onSeriesPress={handleSeriesPress}
|
|
||||||
onSeasonPress={handleSeasonPress}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,41 +1,28 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Dimensions, View } from "react-native";
|
import { Dimensions, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
|
||||||
export const ItemContentSkeletonTV: React.FC = () => {
|
export const ItemContentSkeletonTV: React.FC = () => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
paddingTop: 180,
|
paddingTop: insets.top + 140,
|
||||||
paddingHorizontal: 160,
|
paddingHorizontal: insets.left + 80,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side - Poster placeholder */}
|
{/* Left side - Content placeholders */}
|
||||||
<View
|
<View style={{ flex: 1 }}>
|
||||||
style={{
|
{/* Logo placeholder */}
|
||||||
width: SCREEN_WIDTH * 0.22,
|
|
||||||
marginRight: 50,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: 2 / 3,
|
height: 150,
|
||||||
borderRadius: 16,
|
width: "80%",
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Right side - Content placeholders */}
|
|
||||||
<View style={{ flex: 1, justifyContent: "center" }}>
|
|
||||||
{/* Logo/Title placeholder */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 80,
|
|
||||||
width: "60%",
|
|
||||||
backgroundColor: "#1a1a1a",
|
backgroundColor: "#1a1a1a",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
@@ -155,6 +142,22 @@ export const ItemContentSkeletonTV: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
|||||||
{/* Password Input */}
|
{/* Password Input */}
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
||||||
<Text className='text-neutral-400 text-sm mb-2'>
|
<Text className='text-neutral-400 text-sm mb-2'>
|
||||||
{t("login.password")}
|
{t("login.password_placeholder")}
|
||||||
</Text>
|
</Text>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
value={password}
|
value={password}
|
||||||
@@ -136,7 +136,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
|||||||
setPassword(text);
|
setPassword(text);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder={t("login.password")}
|
placeholder={t("login.password_placeholder")}
|
||||||
placeholderTextColor='#6B7280'
|
placeholderTextColor='#6B7280'
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -174,7 +174,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator size='small' color='white' />
|
<ActivityIndicator size='small' color='white' />
|
||||||
) : (
|
) : (
|
||||||
t("login.login")
|
t("common.login")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
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 { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { chromecast } from "../utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "../utils/profiles/chromecasth265";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|||||||
@@ -73,10 +73,19 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
setLoadingServer(server.address);
|
setLoadingServer(server.address);
|
||||||
try {
|
try {
|
||||||
await onQuickLogin(server.address, account.userId);
|
await onQuickLogin(server.address, account.userId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
Alert.alert(
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t("server.session_expired");
|
||||||
|
const isSessionExpired = errorMessage.includes(
|
||||||
t("server.session_expired"),
|
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) }],
|
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -122,10 +131,17 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
setLoadingServer(selectedServer.address);
|
setLoadingServer(selectedServer.address);
|
||||||
try {
|
try {
|
||||||
await onQuickLogin(selectedServer.address, selectedAccount.userId);
|
await onQuickLogin(selectedServer.address, selectedAccount.userId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
Alert.alert(
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : t("server.session_expired");
|
||||||
|
const isSessionExpired = errorMessage.includes(
|
||||||
t("server.session_expired"),
|
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"),
|
text: t("common.ok"),
|
||||||
|
|||||||
@@ -1,909 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import Animated, {
|
|
||||||
Easing,
|
|
||||||
interpolate,
|
|
||||||
runOnJS,
|
|
||||||
type SharedValue,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { ItemImage } from "../common/ItemImage";
|
|
||||||
import { getItemNavigation } from "../common/TouchableItemRouter";
|
|
||||||
import type { SelectedOptions } from "../ItemContent";
|
|
||||||
import { PlayButton } from "../PlayButton";
|
|
||||||
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
|
||||||
|
|
||||||
interface AppleTVCarouselProps {
|
|
||||||
initialIndex?: number;
|
|
||||||
onItemChange?: (index: number) => void;
|
|
||||||
scrollOffset?: SharedValue<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout Constants
|
|
||||||
const GRADIENT_HEIGHT_TOP = 150;
|
|
||||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
|
||||||
const LOGO_HEIGHT = 80;
|
|
||||||
|
|
||||||
// Position Constants
|
|
||||||
const LOGO_BOTTOM_POSITION = 260;
|
|
||||||
const GENRES_BOTTOM_POSITION = 220;
|
|
||||||
const OVERVIEW_BOTTOM_POSITION = 165;
|
|
||||||
const CONTROLS_BOTTOM_POSITION = 80;
|
|
||||||
const DOTS_BOTTOM_POSITION = 40;
|
|
||||||
|
|
||||||
// Size Constants
|
|
||||||
const DOT_HEIGHT = 6;
|
|
||||||
const DOT_ACTIVE_WIDTH = 20;
|
|
||||||
const DOT_INACTIVE_WIDTH = 12;
|
|
||||||
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
|
||||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
|
||||||
const TEXT_SKELETON_HEIGHT = 20;
|
|
||||||
const TEXT_SKELETON_WIDTH = 250;
|
|
||||||
const OVERVIEW_SKELETON_HEIGHT = 16;
|
|
||||||
const OVERVIEW_SKELETON_WIDTH = 400;
|
|
||||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
|
||||||
|
|
||||||
// Spacing Constants
|
|
||||||
const HORIZONTAL_PADDING = 40;
|
|
||||||
const DOT_PADDING = 2;
|
|
||||||
const DOT_GAP = 4;
|
|
||||||
const CONTROLS_GAP = 10;
|
|
||||||
const _TEXT_MARGIN_TOP = 16;
|
|
||||||
|
|
||||||
// Border Radius Constants
|
|
||||||
const DOT_BORDER_RADIUS = 3;
|
|
||||||
const LOGO_SKELETON_BORDER_RADIUS = 8;
|
|
||||||
const TEXT_SKELETON_BORDER_RADIUS = 4;
|
|
||||||
const PLAY_BUTTON_BORDER_RADIUS = 25;
|
|
||||||
const PLAYED_STATUS_BORDER_RADIUS = 20;
|
|
||||||
|
|
||||||
// Animation Constants
|
|
||||||
const DOT_ANIMATION_DURATION = 300;
|
|
||||||
const CAROUSEL_TRANSITION_DURATION = 250;
|
|
||||||
const PAN_ACTIVE_OFFSET = 10;
|
|
||||||
const TRANSLATION_THRESHOLD = 0.2;
|
|
||||||
const VELOCITY_THRESHOLD = 400;
|
|
||||||
|
|
||||||
// Text Constants
|
|
||||||
const GENRES_FONT_SIZE = 16;
|
|
||||||
const OVERVIEW_FONT_SIZE = 14;
|
|
||||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
|
||||||
const TEXT_SHADOW_RADIUS = 2;
|
|
||||||
const MAX_GENRES_COUNT = 2;
|
|
||||||
const MAX_BUTTON_WIDTH = 300;
|
|
||||||
const OVERVIEW_MAX_LINES = 2;
|
|
||||||
const OVERVIEW_MAX_WIDTH = "80%";
|
|
||||||
|
|
||||||
// Opacity Constants
|
|
||||||
const OVERLAY_OPACITY = 0.3;
|
|
||||||
const DOT_INACTIVE_OPACITY = 0.6;
|
|
||||||
const TEXT_OPACITY = 0.9;
|
|
||||||
|
|
||||||
// Color Constants
|
|
||||||
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
|
|
||||||
const SKELETON_ELEMENT_COLOR = "#333";
|
|
||||||
const SKELETON_ACTIVE_DOT_COLOR = "#666";
|
|
||||||
const _EMPTY_STATE_COLOR = "#666";
|
|
||||||
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
|
|
||||||
const LOGO_WIDTH_PERCENTAGE = "80%";
|
|
||||||
|
|
||||||
const DotIndicator = ({
|
|
||||||
index,
|
|
||||||
currentIndex,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
index: number;
|
|
||||||
currentIndex: number;
|
|
||||||
onPress: (index: number) => void;
|
|
||||||
}) => {
|
|
||||||
const isActive = index === currentIndex;
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
|
|
||||||
duration: DOT_ANIMATION_DURATION,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
}),
|
|
||||||
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
|
|
||||||
duration: DOT_ANIMATION_DURATION,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => onPress(index)}
|
|
||||||
style={{
|
|
||||||
padding: DOT_PADDING, // Increase touch area
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: DOT_HEIGHT,
|
|
||||||
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
|
|
||||||
borderRadius: DOT_BORDER_RADIUS,
|
|
||||||
},
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|
||||||
initialIndex = 0,
|
|
||||||
onItemChange,
|
|
||||||
scrollOffset,
|
|
||||||
}) => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { isConnected, serverConnected } = useNetworkStatus();
|
|
||||||
const router = useRouter();
|
|
||||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
|
||||||
const isLandscape = screenWidth >= screenHeight;
|
|
||||||
const carouselHeight = useMemo(
|
|
||||||
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
|
|
||||||
[isLandscape, screenHeight],
|
|
||||||
);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
||||||
const translateX = useSharedValue(-initialIndex * screenWidth);
|
|
||||||
|
|
||||||
const isQueryEnabled =
|
|
||||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
|
||||||
|
|
||||||
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres", "Overview"],
|
|
||||||
limit: 2,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
|
|
||||||
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres", "Overview"],
|
|
||||||
limit: 2,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
|
|
||||||
{
|
|
||||||
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user.Id,
|
|
||||||
limit: 2,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
});
|
|
||||||
return response.data || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
const continueItems = continueWatchingData ?? [];
|
|
||||||
const nextItems = nextUpData ?? [];
|
|
||||||
const recentItems = recentlyAddedData ?? [];
|
|
||||||
|
|
||||||
const allItems = [
|
|
||||||
...continueItems.slice(0, 2),
|
|
||||||
...nextItems.slice(0, 2),
|
|
||||||
...recentItems.slice(0, 2),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Deduplicate by item ID to prevent duplicate keys
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return allItems.filter((item) => {
|
|
||||||
if (item.Id && !seen.has(item.Id)) {
|
|
||||||
seen.add(item.Id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
|
|
||||||
const hasItems = items.length > 0;
|
|
||||||
|
|
||||||
// Only get play settings if we have valid items
|
|
||||||
const currentItem = hasItems ? items[currentIndex] : null;
|
|
||||||
|
|
||||||
// Extract colors for the current item only (for performance)
|
|
||||||
const currentItemColors = useImageColorsReturn({ item: currentItem });
|
|
||||||
|
|
||||||
// Create a fallback empty item for useDefaultPlaySettings when no item is available
|
|
||||||
const itemForPlaySettings = currentItem || { MediaSources: [] };
|
|
||||||
const {
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultMediaSource,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
|
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
|
||||||
SelectedOptions | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only set options if we have valid current item
|
|
||||||
if (currentItem) {
|
|
||||||
setSelectedOptions({
|
|
||||||
bitrate: defaultBitrate,
|
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSelectedOptions(undefined);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
defaultMediaSource,
|
|
||||||
currentIndex,
|
|
||||||
currentItem,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasItems) {
|
|
||||||
setCurrentIndex(initialIndex);
|
|
||||||
translateX.value = -initialIndex * screenWidth;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentIndex((prev) => {
|
|
||||||
const newIndex = Math.min(prev, items.length - 1);
|
|
||||||
translateX.value = -newIndex * screenWidth;
|
|
||||||
return newIndex;
|
|
||||||
});
|
|
||||||
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
translateX.value = -currentIndex * screenWidth;
|
|
||||||
}, [currentIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasItems) {
|
|
||||||
onItemChange?.(currentIndex);
|
|
||||||
}
|
|
||||||
}, [hasItems, currentIndex, onItemChange]);
|
|
||||||
|
|
||||||
const goToIndex = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (!hasItems || index < 0 || index >= items.length) return;
|
|
||||||
|
|
||||||
translateX.value = withTiming(-index * screenWidth, {
|
|
||||||
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
|
|
||||||
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
|
|
||||||
});
|
|
||||||
|
|
||||||
setCurrentIndex(index);
|
|
||||||
onItemChange?.(index);
|
|
||||||
},
|
|
||||||
[hasItems, items, onItemChange, screenWidth, translateX],
|
|
||||||
);
|
|
||||||
|
|
||||||
const navigateToItem = useCallback(
|
|
||||||
(item: BaseItemDto) => {
|
|
||||||
const navigation = getItemNavigation(item, "(home)");
|
|
||||||
router.push(navigation as any);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const panGesture = Gesture.Pan()
|
|
||||||
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
|
|
||||||
.onUpdate((event) => {
|
|
||||||
translateX.value = -currentIndex * screenWidth + event.translationX;
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
const velocity = event.velocityX;
|
|
||||||
const translation = event.translationX;
|
|
||||||
|
|
||||||
let newIndex = currentIndex;
|
|
||||||
|
|
||||||
// Improved thresholds for more responsive navigation
|
|
||||||
if (
|
|
||||||
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
|
|
||||||
Math.abs(velocity) > VELOCITY_THRESHOLD
|
|
||||||
) {
|
|
||||||
if (translation > 0 && currentIndex > 0) {
|
|
||||||
newIndex = currentIndex - 1;
|
|
||||||
} else if (
|
|
||||||
translation < 0 &&
|
|
||||||
items &&
|
|
||||||
currentIndex < items.length - 1
|
|
||||||
) {
|
|
||||||
newIndex = currentIndex + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runOnJS(goToIndex)(newIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
const containerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [{ translateX: translateX.value }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
|
||||||
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
if (!scrollOffset) return {};
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[-carouselHeight / 2, 0, carouselHeight * 0.75],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[2, 1, 1],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderDots = () => {
|
|
||||||
if (!hasItems || items.length <= 1) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: DOTS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: DOT_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map((_, index) => (
|
|
||||||
<DotIndicator
|
|
||||||
key={index}
|
|
||||||
index={index}
|
|
||||||
currentIndex={currentIndex}
|
|
||||||
onPress={goToIndex}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSkeletonLoader = () => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: screenWidth,
|
|
||||||
height: carouselHeight,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: SKELETON_BACKGROUND_COLOR,
|
|
||||||
position: "absolute",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dark Overlay Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black Top Skeleton */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: GRADIENT_HEIGHT_TOP,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black Bottom Skeleton */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: GRADIENT_HEIGHT_BOTTOM,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logo Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: LOGO_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: LOGO_HEIGHT,
|
|
||||||
width: LOGO_WIDTH_PERCENTAGE,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Type and Genres Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: GENRES_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: TEXT_SKELETON_HEIGHT,
|
|
||||||
width: TEXT_SKELETON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Overview Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Controls Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: CONTROLS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: CONTROLS_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Play Button Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: PLAY_BUTTON_SKELETON_HEIGHT,
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: MAX_BUTTON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Played Status Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: PLAYED_STATUS_SKELETON_SIZE,
|
|
||||||
height: PLAYED_STATUS_SKELETON_SIZE,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Dots Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: DOTS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: DOT_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[1, 2, 3].map((_, index) => (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
|
|
||||||
height: DOT_HEIGHT,
|
|
||||||
backgroundColor:
|
|
||||||
index === 0
|
|
||||||
? SKELETON_ACTIVE_DOT_COLOR
|
|
||||||
: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: DOT_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderItem = (item: BaseItemDto, _index: number) => {
|
|
||||||
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={item.Id}
|
|
||||||
style={{
|
|
||||||
width: screenWidth,
|
|
||||||
height: carouselHeight,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background Backdrop */}
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "absolute",
|
|
||||||
},
|
|
||||||
headerAnimatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ItemImage
|
|
||||||
item={item}
|
|
||||||
variant='Backdrop'
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Dark Overlay */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black at Top */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: GRADIENT_HEIGHT_TOP,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black at Bottom */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: GRADIENT_HEIGHT_BOTTOM,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logo Section */}
|
|
||||||
{itemLogoUrl && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => navigateToItem(item)}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: LOGO_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: itemLogoUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: LOGO_HEIGHT,
|
|
||||||
width: LOGO_WIDTH_PERCENTAGE,
|
|
||||||
}}
|
|
||||||
contentFit='contain'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Type and Genres Section */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: GENRES_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
||||||
<Animated.Text
|
|
||||||
style={{
|
|
||||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
|
|
||||||
fontSize: GENRES_FONT_SIZE,
|
|
||||||
fontWeight: "500",
|
|
||||||
textAlign: "center",
|
|
||||||
textShadowColor: TEXT_SHADOW_COLOR,
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
let typeLabel = "";
|
|
||||||
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
// For episodes, show season and episode number
|
|
||||||
const season = item.ParentIndexNumber;
|
|
||||||
const episode = item.IndexNumber;
|
|
||||||
if (season && episode) {
|
|
||||||
typeLabel = `S${season} • E${episode}`;
|
|
||||||
} else {
|
|
||||||
typeLabel = "Episode";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
typeLabel =
|
|
||||||
item.Type === "Series"
|
|
||||||
? "TV Show"
|
|
||||||
: item.Type === "Movie"
|
|
||||||
? "Movie"
|
|
||||||
: item.Type || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const genres =
|
|
||||||
item.Genres && item.Genres.length > 0
|
|
||||||
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (typeLabel && genres) {
|
|
||||||
return `${typeLabel} • ${genres}`;
|
|
||||||
} else if (typeLabel) {
|
|
||||||
return typeLabel;
|
|
||||||
} else if (genres) {
|
|
||||||
return genres;
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</Animated.Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Overview Section - for Episodes and Movies */}
|
|
||||||
{(item.Type === "Episode" || item.Type === "Movie") &&
|
|
||||||
item.Overview && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
||||||
<Animated.Text
|
|
||||||
numberOfLines={OVERVIEW_MAX_LINES}
|
|
||||||
style={{
|
|
||||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
|
||||||
fontSize: OVERVIEW_FONT_SIZE,
|
|
||||||
fontWeight: "400",
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: OVERVIEW_MAX_WIDTH,
|
|
||||||
textShadowColor: TEXT_SHADOW_COLOR,
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.Overview}
|
|
||||||
</Animated.Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: CONTROLS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: CONTROLS_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Play Button */}
|
|
||||||
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
|
|
||||||
{selectedOptions && (
|
|
||||||
<PlayButton
|
|
||||||
item={item}
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
colors={currentItemColors}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Mark as Played */}
|
|
||||||
<MarkAsPlayedLargeButton
|
|
||||||
isPlayed={item.UserData?.Played ?? false}
|
|
||||||
onToggle={togglePlayedStatus}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle loading state
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: carouselHeight,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderSkeletonLoader()}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty items
|
|
||||||
if (!hasItems) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
|
||||||
backgroundColor: "#000",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GestureDetector gesture={panGesture}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
|
||||||
flexDirection: "row",
|
|
||||||
width: screenWidth * items.length,
|
|
||||||
},
|
|
||||||
containerAnimatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => renderItem(item, index))}
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
|
|
||||||
{/* Animated Dots Indicator */}
|
|
||||||
{renderDots()}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Button, Host } from "@expo/ui/swift-ui";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Platform, View } from "react-native";
|
|
||||||
import { RoundButton } from "../RoundButton";
|
|
||||||
|
|
||||||
interface MarkAsPlayedLargeButtonProps {
|
|
||||||
isPlayed: boolean;
|
|
||||||
onToggle: (isPlayed: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MarkAsPlayedLargeButton: React.FC<
|
|
||||||
MarkAsPlayedLargeButtonProps
|
|
||||||
> = ({ isPlayed, onToggle }) => {
|
|
||||||
if (Platform.OS === "ios")
|
|
||||||
return (
|
|
||||||
<Host
|
|
||||||
style={{
|
|
||||||
flex: 0,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
flexDirection: "row",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
|
|
||||||
<View>
|
|
||||||
<Ionicons
|
|
||||||
name='checkmark'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
style={{
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Button>
|
|
||||||
</Host>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<RoundButton
|
|
||||||
size='large'
|
|
||||||
icon={isPlayed ? "checkmark" : "checkmark"}
|
|
||||||
onPress={() => onToggle(isPlayed)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,7 @@ export function Text(props: TextProps) {
|
|||||||
if (Platform.isTV)
|
if (Platform.isTV)
|
||||||
return (
|
return (
|
||||||
<RNText
|
<RNText
|
||||||
allowFontScaling={true}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import { type PropsWithChildren, useCallback } from "react";
|
import { type PropsWithChildren, useCallback } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import {
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity,
|
||||||
|
type TouchableOpacityProps,
|
||||||
|
} from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
@@ -121,6 +125,12 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "Playlist") {
|
if (item.Type === "Playlist") {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return {
|
||||||
|
pathname: "/[libraryId]" as const,
|
||||||
|
params: { libraryId: item.Id! },
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
pathname: "/music/playlist/[playlistId]" as const,
|
pathname: "/music/playlist/[playlistId]" as const,
|
||||||
params: { playlistId: item.Id! },
|
params: { playlistId: item.Id! },
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import heart from "@/assets/icons/heart.fill.png";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
@@ -28,6 +28,7 @@ type FavoriteTypes =
|
|||||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||||
|
|
||||||
export const Favorites = () => {
|
export const Favorites = () => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -148,7 +149,7 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
@@ -160,7 +161,7 @@ export const Favorites = () => {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -177,8 +178,6 @@ export const Favorites = () => {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top + TOP_PADDING,
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
paddingBottom: insets.bottom + 60,
|
paddingBottom: insets.bottom + 60,
|
||||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
|
||||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
|
|||||||
@@ -598,11 +598,14 @@ const HomeMobile = () => {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
// Render Streamystats sections after Recently Added sections
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
// For custom sections: place at the very end
|
||||||
? 0
|
const hasSuggestedMovies =
|
||||||
: 1;
|
!settings?.streamyStatsMovieRecommendations &&
|
||||||
|
!settings?.home?.sections;
|
||||||
|
const streamystatsIndex =
|
||||||
|
sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||||
const hasStreamystatsContent =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
|
|||||||
@@ -30,19 +30,21 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
|
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
|
||||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
|
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
|
||||||
|
import { TVHeroCarousel } from "@/components/home/TVHeroCarousel";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
const TOP_PADDING = 100;
|
const TOP_PADDING = 100;
|
||||||
// Reduced gap since sections have internal padding for scale animations
|
// Generous gap between sections for Apple TV+ aesthetic
|
||||||
const SECTION_GAP = 10;
|
const SECTION_GAP = 24;
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "InfiniteScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
@@ -51,7 +53,6 @@ type InfiniteScrollingCollectionListSection = {
|
|||||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
priority?: 1 | 2;
|
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ type Section = InfiniteScrollingCollectionListSection;
|
|||||||
const BACKDROP_DEBOUNCE_MS = 300;
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const _router = useRouter();
|
const _router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -75,7 +77,7 @@ export const Home = () => {
|
|||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
// Dynamic backdrop state with debounce
|
// Dynamic backdrop state with debounce
|
||||||
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||||
@@ -201,6 +203,58 @@ export const Home = () => {
|
|||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch hero items (Continue Watching + Next Up combined)
|
||||||
|
const { data: heroItems } = useQuery({
|
||||||
|
queryKey: ["home", "heroItems", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||||
|
getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
fields: ["Overview"],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user.Id,
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 10,
|
||||||
|
fields: ["Overview"],
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resumeItems = resumeResponse.data.Items || [];
|
||||||
|
const nextUpItems = nextUpResponse.data.Items || [];
|
||||||
|
|
||||||
|
// Combine, sort by recent activity, and dedupe
|
||||||
|
const combined = [...resumeItems, ...nextUpItems];
|
||||||
|
const sorted = combined.sort((a, b) => {
|
||||||
|
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||||
|
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||||
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: BaseItemDto[] = [];
|
||||||
|
for (const item of sorted) {
|
||||||
|
if (!item.Id || seen.has(item.Id)) continue;
|
||||||
|
seen.add(item.Id);
|
||||||
|
deduped.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped.slice(0, 15);
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userViews = useMemo(
|
const userViews = useMemo(
|
||||||
@@ -327,7 +381,6 @@ export const Home = () => {
|
|||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
priority: 1,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@@ -347,7 +400,6 @@ export const Home = () => {
|
|||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
priority: 1,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("home.next_up"),
|
title: t("home.next_up"),
|
||||||
@@ -365,13 +417,12 @@ export const Home = () => {
|
|||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
priority: 1,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
...firstSections,
|
...firstSections,
|
||||||
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
...latestMediaViews,
|
||||||
...(!settings?.streamyStatsMovieRecommendations
|
...(!settings?.streamyStatsMovieRecommendations
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -390,7 +441,6 @@ export const Home = () => {
|
|||||||
type: "InfiniteScrollingCollectionList" as const,
|
type: "InfiniteScrollingCollectionList" as const,
|
||||||
orientation: "vertical" as const,
|
orientation: "vertical" as const,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
priority: 2 as const,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -475,7 +525,6 @@ export const Home = () => {
|
|||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: section?.orientation || "vertical",
|
orientation: section?.orientation || "vertical",
|
||||||
pageSize,
|
pageSize,
|
||||||
priority: index < 2 ? 1 : 2,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return ss;
|
return ss;
|
||||||
@@ -483,23 +532,21 @@ export const Home = () => {
|
|||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
const highPrioritySectionKeys = useMemo(() => {
|
// Determine if hero should be shown (separate setting from backdrop)
|
||||||
return sections
|
// We need this early to calculate which sections will actually be rendered
|
||||||
.filter((s) => s.priority === 1)
|
const showHero = useMemo(() => {
|
||||||
.map((s) => s.queryKey.join("-"));
|
return heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
|
||||||
}, [sections]);
|
}, [heroItems, settings.showTVHeroCarousel]);
|
||||||
|
|
||||||
const allHighPriorityLoaded = useMemo(() => {
|
// Get sections that will actually be rendered (accounting for hero slicing)
|
||||||
return highPrioritySectionKeys.every((key) => loadedSections.has(key));
|
// When hero is shown, skip the first sections since hero already displays that content
|
||||||
}, [highPrioritySectionKeys, loadedSections]);
|
// - If mergeNextUpAndContinueWatching: skip 1 section (combined Continue & Next Up)
|
||||||
|
// - Otherwise: skip 2 sections (separate Continue Watching + Next Up)
|
||||||
const markSectionLoaded = useCallback(
|
const renderedSections = useMemo(() => {
|
||||||
(queryKey: (string | undefined | null)[]) => {
|
if (!showHero) return sections;
|
||||||
const key = queryKey.join("-");
|
const sectionsToSkip = settings.mergeNextUpAndContinueWatching ? 1 : 2;
|
||||||
setLoadedSections((prev) => new Set(prev).add(key));
|
return sections.slice(sectionsToSkip);
|
||||||
},
|
}, [sections, showHero, settings.mergeNextUpAndContinueWatching]);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isConnected || serverConnected !== true) {
|
if (!isConnected || serverConnected !== true) {
|
||||||
let title = "";
|
let title = "";
|
||||||
@@ -526,7 +573,7 @@ export const Home = () => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
@@ -538,7 +585,7 @@ export const Home = () => {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -579,7 +626,7 @@ export const Home = () => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
@@ -591,7 +638,7 @@ export const Home = () => {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -609,82 +656,101 @@ export const Home = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
{/* Dynamic backdrop with crossfade */}
|
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
|
||||||
<View
|
{!showHero && settings.showHomeBackdrop && (
|
||||||
style={{
|
<View
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Layer 0 */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
opacity: layer0Opacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{layer0Url && (
|
|
||||||
<Image
|
|
||||||
source={{ uri: layer0Url }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
{/* Layer 1 */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
opacity: layer1Opacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{layer1Url && (
|
|
||||||
<Image
|
|
||||||
source={{ uri: layer1Url }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
{/* Gradient overlays for readability */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
|
||||||
locations={[0, 0.4, 1]}
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
height: "100%",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</View>
|
{/* Layer 0 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer0Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer0Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer0Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Layer 1 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer1Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer1Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer1Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Gradient overlays for readability */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.4, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top + TOP_PADDING,
|
paddingTop: showHero ? 0 : insets.top + TOP_PADDING,
|
||||||
paddingBottom: insets.bottom + 60,
|
paddingBottom: insets.bottom + 60,
|
||||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
|
||||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
{/* Hero Carousel - Apple TV+ style featured content */}
|
||||||
{sections.map((section, index) => {
|
{showHero && heroItems && (
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
<TVHeroCarousel
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
items={heroItems}
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
onItemFocus={handleItemFocus}
|
||||||
? 0
|
onItemLongPress={showItemActions}
|
||||||
: 1;
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
gap: SECTION_GAP,
|
||||||
|
paddingTop: showHero ? SECTION_GAP : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
|
||||||
|
{renderedSections.map((section, index) => {
|
||||||
|
// Render Streamystats sections after Recently Added sections
|
||||||
|
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||||
|
// For custom sections: place at the very end
|
||||||
|
const hasSuggestedMovies =
|
||||||
|
!settings?.streamyStatsMovieRecommendations &&
|
||||||
|
!settings?.home?.sections;
|
||||||
|
const displayedSectionsLength = renderedSections.length;
|
||||||
|
const streamystatsIndex =
|
||||||
|
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||||
const hasStreamystatsContent =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
@@ -698,7 +764,6 @@ export const Home = () => {
|
|||||||
"home.settings.plugins.streamystats.recommended_movies",
|
"home.settings.plugins.streamystats.recommended_movies",
|
||||||
)}
|
)}
|
||||||
type='Movie'
|
type='Movie'
|
||||||
enabled={allHighPriorityLoaded}
|
|
||||||
onItemFocus={handleItemFocus}
|
onItemFocus={handleItemFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -708,13 +773,11 @@ export const Home = () => {
|
|||||||
"home.settings.plugins.streamystats.recommended_series",
|
"home.settings.plugins.streamystats.recommended_series",
|
||||||
)}
|
)}
|
||||||
type='Series'
|
type='Series'
|
||||||
enabled={allHighPriorityLoaded}
|
|
||||||
onItemFocus={handleItemFocus}
|
onItemFocus={handleItemFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{settings.streamyStatsPromotedWatchlists && (
|
{settings.streamyStatsPromotedWatchlists && (
|
||||||
<StreamystatsPromotedWatchlists
|
<StreamystatsPromotedWatchlists
|
||||||
enabled={allHighPriorityLoaded}
|
|
||||||
onItemFocus={handleItemFocus}
|
onItemFocus={handleItemFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -722,8 +785,8 @@ export const Home = () => {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
const isHighPriority = section.priority === 1;
|
// First section only gets preferred focus if hero is not shown
|
||||||
const isFirstSection = index === 0;
|
const isFirstSection = index === 0 && !showHero;
|
||||||
return (
|
return (
|
||||||
<View key={index} style={{ gap: SECTION_GAP }}>
|
<View key={index} style={{ gap: SECTION_GAP }}>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
@@ -733,12 +796,6 @@ export const Home = () => {
|
|||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={section.pageSize}
|
pageSize={section.pageSize}
|
||||||
enabled={isHighPriority || allHighPriorityLoaded}
|
|
||||||
onLoaded={
|
|
||||||
isHighPriority
|
|
||||||
? () => markSectionLoaded(section.queryKey)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
isFirstSection={isFirstSection}
|
isFirstSection={isFirstSection}
|
||||||
onItemFocus={handleItemFocus}
|
onItemFocus={handleItemFocus}
|
||||||
parentId={section.parentId}
|
parentId={section.parentId}
|
||||||
|
|||||||
@@ -1,631 +0,0 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemDtoQueryResult,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getSuggestionsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
getUserViewsApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useNavigation, useSegments } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
|
||||||
useAnimatedRef,
|
|
||||||
useScrollViewOffset,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
|
||||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
|
||||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { eventBus } from "@/utils/eventBus";
|
|
||||||
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
|
||||||
type: "InfiniteScrollingCollectionList";
|
|
||||||
title?: string;
|
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
pageSize?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaListSectionType = {
|
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
|
||||||
|
|
||||||
export const HomeWithCarousel = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [_loading, setLoading] = useState(false);
|
|
||||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
|
||||||
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
||||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
|
||||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
|
||||||
const {
|
|
||||||
isConnected,
|
|
||||||
serverConnected,
|
|
||||||
loading: retryLoading,
|
|
||||||
retryCheck,
|
|
||||||
} = useNetworkStatus();
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
const [scrollY, setScrollY] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isConnected && !prevIsConnected.current) {
|
|
||||||
invalidateCache();
|
|
||||||
}
|
|
||||||
prevIsConnected.current = isConnected;
|
|
||||||
}, [isConnected, invalidateCache]);
|
|
||||||
|
|
||||||
const hasDownloads = useMemo(() => {
|
|
||||||
if (Platform.isTV) return false;
|
|
||||||
return downloadedItems.length > 0;
|
|
||||||
}, [downloadedItems]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className='ml-1.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name='download'
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [navigation, router, hasDownloads]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cleanCacheDirectory().catch((_e) =>
|
|
||||||
console.error("Something went wrong cleaning cache directory"),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const segments = useSegments();
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
|
||||||
if ((segments as string[])[2] === "(home)")
|
|
||||||
animatedScrollRef.current?.scrollTo({
|
|
||||||
y: Platform.isTV ? -152 : -100,
|
|
||||||
animated: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [segments]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isError: e1,
|
|
||||||
isLoading: l1,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "userViews", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const userViews = useMemo(
|
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
|
||||||
[data, settings?.hiddenLibraries],
|
|
||||||
);
|
|
||||||
|
|
||||||
const collections = useMemo(() => {
|
|
||||||
const allow = ["movies", "tvshows"];
|
|
||||||
return (
|
|
||||||
userViews?.filter(
|
|
||||||
(c) => c.CollectionType && allow.includes(c.CollectionType),
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [userViews]);
|
|
||||||
|
|
||||||
const _refetch = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
|
||||||
(
|
|
||||||
title: string,
|
|
||||||
queryKey: string[],
|
|
||||||
includeItemTypes: BaseItemKind[],
|
|
||||||
parentId: string | undefined,
|
|
||||||
pageSize: number = 10,
|
|
||||||
): InfiniteScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
if (!api) return [];
|
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 100, // Fetch a larger set for pagination
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || [];
|
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
|
||||||
},
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
pageSize,
|
|
||||||
}),
|
|
||||||
[api, user?.Id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultSections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
|
||||||
const includeItemTypes: BaseItemKind[] =
|
|
||||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
|
||||||
? []
|
|
||||||
: ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
|
||||||
const queryKey: string[] = [
|
|
||||||
"home",
|
|
||||||
`recentlyAddedIn${c.CollectionType}`,
|
|
||||||
user.Id!,
|
|
||||||
c.Id!,
|
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to sort items by most recent activity
|
|
||||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
|
||||||
return items.sort((a, b) => {
|
|
||||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
|
||||||
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
|
||||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to deduplicate items by ID
|
|
||||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return items.filter((item) => {
|
|
||||||
if (!item.Id || seen.has(item.Id)) return false;
|
|
||||||
seen.add(item.Id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build the first sections based on merge setting
|
|
||||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: t("home.continue_and_next_up"),
|
|
||||||
queryKey: ["home", "continueAndNextUp"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
// Fetch both in parallel
|
|
||||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
|
||||||
getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: 0,
|
|
||||||
limit: 20,
|
|
||||||
}),
|
|
||||||
getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: 0,
|
|
||||||
limit: 20,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const resumeItems = resumeResponse.data.Items || [];
|
|
||||||
const nextUpItems = nextUpResponse.data.Items || [];
|
|
||||||
|
|
||||||
// Combine, sort by recent activity, deduplicate
|
|
||||||
const combined = [...resumeItems, ...nextUpItems];
|
|
||||||
const sorted = sortByRecentActivity(combined);
|
|
||||||
const deduplicated = deduplicateById(sorted);
|
|
||||||
|
|
||||||
// Paginate client-side
|
|
||||||
return deduplicated.slice(pageParam, pageParam + 10);
|
|
||||||
},
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
title: t("home.continue_watching"),
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.next_up"),
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
...firstSections,
|
|
||||||
...latestMediaViews,
|
|
||||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
|
||||||
...(!settings?.streamyStatsMovieRecommendations
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: t("home.suggested_movies"),
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList" as const,
|
|
||||||
orientation: "vertical" as const,
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
return ss;
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
collections,
|
|
||||||
t,
|
|
||||||
createCollectionConfig,
|
|
||||||
settings?.streamyStatsMovieRecommendations,
|
|
||||||
settings.mergeNextUpAndContinueWatching,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const customSections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
|
||||||
const ss: Section[] = [];
|
|
||||||
settings.home.sections.forEach((section, index) => {
|
|
||||||
const id = section.title || `section-${index}`;
|
|
||||||
const pageSize = 10;
|
|
||||||
ss.push({
|
|
||||||
title: t(`${id}`),
|
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
if (section.items) {
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: section.items?.limit || pageSize,
|
|
||||||
recursive: true,
|
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
|
||||||
sortBy: section.items?.sortBy,
|
|
||||||
sortOrder: section.items?.sortOrder,
|
|
||||||
filters: section.items?.filters,
|
|
||||||
parentId: section.items?.parentId,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
if (section.nextUp) {
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: section.nextUp?.limit || pageSize,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
if (section.latest) {
|
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
|
||||||
limit: section.latest?.limit || 100, // Fetch larger set
|
|
||||||
isPlayed: section.latest?.isPlayed,
|
|
||||||
groupItems: section.latest?.groupItems,
|
|
||||||
})
|
|
||||||
).data || [];
|
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
|
||||||
}
|
|
||||||
if (section.custom) {
|
|
||||||
const response = await api.get<BaseItemDtoQueryResult>(
|
|
||||||
section.custom.endpoint,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
...(section.custom.query || {}),
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: pageSize,
|
|
||||||
},
|
|
||||||
headers: section.custom.headers || {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: section?.orientation || "vertical",
|
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
|
||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
|
||||||
|
|
||||||
if (!isConnected || serverConnected !== true) {
|
|
||||||
let title = "";
|
|
||||||
let subtitle = "";
|
|
||||||
|
|
||||||
if (!isConnected) {
|
|
||||||
title = t("home.no_internet");
|
|
||||||
subtitle = t("home.no_internet_message");
|
|
||||||
} else if (serverConnected === null) {
|
|
||||||
title = t("home.checking_server_connection");
|
|
||||||
subtitle = t("home.checking_server_connection_message");
|
|
||||||
} else if (!serverConnected) {
|
|
||||||
title = t("home.server_unreachable");
|
|
||||||
subtitle = t("home.server_unreachable_message");
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
|
||||||
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
|
||||||
<Text className='text-center opacity-70'>{subtitle}</Text>
|
|
||||||
|
|
||||||
<View className='mt-4'>
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<Button
|
|
||||||
color='purple'
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify='center'
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name='arrow-forward' size={20} color='white' />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("home.go_to_downloads")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color='black'
|
|
||||||
onPress={retryCheck}
|
|
||||||
justify='center'
|
|
||||||
className='mt-2'
|
|
||||||
iconRight={
|
|
||||||
retryLoading ? null : (
|
|
||||||
<Ionicons name='refresh' size={20} color='white' />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{retryLoading ? (
|
|
||||||
<ActivityIndicator size='small' color='white' />
|
|
||||||
) : (
|
|
||||||
t("home.retry")
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e1)
|
|
||||||
return (
|
|
||||||
<View className='flex flex-col items-center justify-center h-full -mt-6'>
|
|
||||||
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
|
|
||||||
<Text className='text-center opacity-70'>
|
|
||||||
{t("home.error_message")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1)
|
|
||||||
return (
|
|
||||||
<View className='justify-center items-center h-full'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.ScrollView
|
|
||||||
scrollToOverflowEnabled={true}
|
|
||||||
ref={animatedScrollRef}
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior='never'
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
bounces={false}
|
|
||||||
overScrollMode='never'
|
|
||||||
style={{ marginTop: -headerOverlayOffset }}
|
|
||||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
|
||||||
onScroll={(event) => {
|
|
||||||
setScrollY(event.nativeEvent.contentOffset.y);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
paddingTop: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col space-y-4'>
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
|
||||||
? 0
|
|
||||||
: 1;
|
|
||||||
const hasStreamystatsContent =
|
|
||||||
settings.streamyStatsMovieRecommendations ||
|
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
|
||||||
settings.streamyStatsPromotedWatchlists;
|
|
||||||
const streamystatsSections =
|
|
||||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
|
||||||
<>
|
|
||||||
{settings.streamyStatsMovieRecommendations && (
|
|
||||||
<StreamystatsRecommendations
|
|
||||||
title={t(
|
|
||||||
"home.settings.plugins.streamystats.recommended_movies",
|
|
||||||
)}
|
|
||||||
type='Movie'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{settings.streamyStatsSeriesRecommendations && (
|
|
||||||
<StreamystatsRecommendations
|
|
||||||
title={t(
|
|
||||||
"home.settings.plugins.streamystats.recommended_series",
|
|
||||||
)}
|
|
||||||
type='Series'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{settings.streamyStatsPromotedWatchlists && (
|
|
||||||
<StreamystatsPromotedWatchlists />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
|
||||||
return (
|
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
|
||||||
<InfiniteScrollingCollectionList
|
|
||||||
title={section.title}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
orientation={section.orientation}
|
|
||||||
hideIfEmpty
|
|
||||||
pageSize={section.pageSize}
|
|
||||||
/>
|
|
||||||
{streamystatsSections}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (section.type === "MediaListSection") {
|
|
||||||
return (
|
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
|
||||||
<MediaListSection
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
scrollY={scrollY}
|
|
||||||
enableLazyLoading={true}
|
|
||||||
/>
|
|
||||||
{streamystatsSections}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className='h-24' />
|
|
||||||
</Animated.ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -16,19 +16,15 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
import MoviePoster, {
|
|
||||||
TV_POSTER_WIDTH,
|
|
||||||
} from "@/components/posters/MoviePoster.tv";
|
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
|
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import ContinueWatchingPoster, {
|
|
||||||
TV_LANDSCAPE_WIDTH,
|
|
||||||
} from "../ContinueWatchingPoster.tv";
|
|
||||||
import SeriesPoster from "../posters/SeriesPoster.tv";
|
|
||||||
|
|
||||||
const ITEM_GAP = 16;
|
|
||||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
const SCALE_PADDING = 20;
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
@@ -42,59 +38,13 @@ interface Props extends ViewProps {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
onPressSeeAll?: () => void;
|
onPressSeeAll?: () => void;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
onLoaded?: () => void;
|
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
onItemFocus?: (item: BaseItemDto) => void;
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TV-specific ItemCardText with larger fonts
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
type PosterSizes = ReturnType<typeof useScaledTVPosterSizes>;
|
||||||
return (
|
|
||||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
|
||||||
{item.Type === "Episode" ? (
|
|
||||||
<>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{
|
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
|
||||||
{" - "}
|
|
||||||
{item.SeriesName}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// TV-specific "See All" card for end of lists
|
// TV-specific "See All" card for end of lists
|
||||||
const TVSeeAllCard: React.FC<{
|
const TVSeeAllCard: React.FC<{
|
||||||
@@ -103,10 +53,20 @@ const TVSeeAllCard: React.FC<{
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => {
|
typography: Typography;
|
||||||
|
posterSizes: PosterSizes;
|
||||||
|
}> = ({
|
||||||
|
onPress,
|
||||||
|
orientation,
|
||||||
|
disabled,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
typography,
|
||||||
|
posterSizes,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const width =
|
const width =
|
||||||
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
|
||||||
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
|
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -137,7 +97,7 @@ const TVSeeAllCard: React.FC<{
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
}}
|
}}
|
||||||
@@ -159,70 +119,49 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onLoaded,
|
|
||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
onItemFocus,
|
onItemFocus,
|
||||||
parentId,
|
parentId,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
|
const ITEM_GAP = sizes.gaps.item;
|
||||||
const effectivePageSize = Math.max(1, pageSize);
|
const effectivePageSize = Math.max(1, pageSize);
|
||||||
const hasCalledOnLoaded = useRef(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
// Track focus within section for item focus/blur callbacks
|
|
||||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||||
const [_focusedCount, setFocusedCount] = useState(0);
|
|
||||||
|
|
||||||
|
// Pass through focus callbacks without tracking internal state
|
||||||
const handleItemFocus = useCallback(
|
const handleItemFocus = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
setFocusedCount((c) => c + 1);
|
|
||||||
onItemFocus?.(item);
|
onItemFocus?.(item);
|
||||||
},
|
},
|
||||||
[onItemFocus],
|
[onItemFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleItemBlur = useCallback(() => {
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
setFocusedCount((c) => Math.max(0, c - 1));
|
useInfiniteQuery({
|
||||||
}, []);
|
queryKey: queryKey,
|
||||||
|
queryFn: ({ pageParam = 0, ...context }) =>
|
||||||
// Focus handler for See All card (doesn't need item parameter)
|
queryFn({ ...context, queryKey, pageParam }),
|
||||||
const handleSeeAllFocus = useCallback(() => {
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
setFocusedCount((c) => c + 1);
|
if (lastPage.length < effectivePageSize) {
|
||||||
}, []);
|
return undefined;
|
||||||
|
}
|
||||||
const {
|
return allPages.reduce((acc, page) => acc + page.length, 0);
|
||||||
data,
|
},
|
||||||
isLoading,
|
initialPageParam: 0,
|
||||||
isFetchingNextPage,
|
staleTime: 60 * 1000,
|
||||||
hasNextPage,
|
refetchInterval: 60 * 1000,
|
||||||
fetchNextPage,
|
refetchOnWindowFocus: false,
|
||||||
isSuccess,
|
refetchOnReconnect: true,
|
||||||
} = useInfiniteQuery({
|
enabled,
|
||||||
queryKey: queryKey,
|
});
|
||||||
queryFn: ({ pageParam = 0, ...context }) =>
|
|
||||||
queryFn({ ...context, queryKey, pageParam }),
|
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
|
||||||
if (lastPage.length < effectivePageSize) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return allPages.reduce((acc, page) => acc + page.length, 0);
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
|
|
||||||
hasCalledOnLoaded.current = true;
|
|
||||||
onLoaded();
|
|
||||||
}
|
|
||||||
}, [isSuccess, onLoaded]);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -243,7 +182,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const itemWidth =
|
const itemWidth =
|
||||||
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
|
||||||
|
|
||||||
const handleItemPress = useCallback(
|
const handleItemPress = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
@@ -271,79 +210,21 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
} as any);
|
} as any);
|
||||||
}, [router, parentId]);
|
}, [router, parentId]);
|
||||||
|
|
||||||
const getItemLayout = useCallback(
|
|
||||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
|
||||||
length: itemWidth + ITEM_GAP,
|
|
||||||
offset: (itemWidth + ITEM_GAP) * index,
|
|
||||||
index,
|
|
||||||
}),
|
|
||||||
[itemWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||||
const isFirstItem = isFirstSection && index === 0;
|
const isFirstItem = isFirstSection && index === 0;
|
||||||
const isHorizontal = orientation === "horizontal";
|
|
||||||
|
|
||||||
const renderPoster = () => {
|
|
||||||
if (item.Type === "Episode" && isHorizontal) {
|
|
||||||
return <ContinueWatchingPoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Episode" && !isHorizontal) {
|
|
||||||
return <SeriesPoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Movie" && isHorizontal) {
|
|
||||||
return <ContinueWatchingPoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Movie" && !isHorizontal) {
|
|
||||||
return <MoviePoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Series" && !isHorizontal) {
|
|
||||||
return <SeriesPoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Series" && isHorizontal) {
|
|
||||||
return <ContinueWatchingPoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Program") {
|
|
||||||
return <ContinueWatchingPoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "BoxSet" && !isHorizontal) {
|
|
||||||
return <MoviePoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "BoxSet" && isHorizontal) {
|
|
||||||
return <ContinueWatchingPoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Playlist" && !isHorizontal) {
|
|
||||||
return <MoviePoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Playlist" && isHorizontal) {
|
|
||||||
return <ContinueWatchingPoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Video" && !isHorizontal) {
|
|
||||||
return <MoviePoster item={item} />;
|
|
||||||
}
|
|
||||||
if (item.Type === "Video" && isHorizontal) {
|
|
||||||
return <ContinueWatchingPoster item={item} />;
|
|
||||||
}
|
|
||||||
// Default fallback
|
|
||||||
return isHorizontal ? (
|
|
||||||
<ContinueWatchingPoster item={item} />
|
|
||||||
) : (
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
|
<View style={{ marginRight: ITEM_GAP }}>
|
||||||
<TVFocusablePoster
|
<TVPosterCard
|
||||||
|
item={item}
|
||||||
|
orientation={orientation}
|
||||||
onPress={() => handleItemPress(item)}
|
onPress={() => handleItemPress(item)}
|
||||||
|
onLongPress={() => showItemActions(item)}
|
||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
onFocus={() => handleItemFocus(item)}
|
onFocus={() => handleItemFocus(item)}
|
||||||
onBlur={handleItemBlur}
|
width={itemWidth}
|
||||||
>
|
/>
|
||||||
{renderPoster()}
|
|
||||||
</TVFocusablePoster>
|
|
||||||
<TVItemCardText item={item} />
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -352,8 +233,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
isFirstSection,
|
isFirstSection,
|
||||||
itemWidth,
|
itemWidth,
|
||||||
handleItemPress,
|
handleItemPress,
|
||||||
|
showItemActions,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
handleItemBlur,
|
ITEM_GAP,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -365,11 +247,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 20,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.horizontal,
|
||||||
|
letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -379,8 +262,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#737373",
|
color: "#737373",
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.horizontal,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("home.no_items")}
|
{t("home.no_items")}
|
||||||
@@ -420,7 +303,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
color: "#262626",
|
color: "#262626",
|
||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -444,12 +327,15 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
maxToRenderPerBatch={3}
|
maxToRenderPerBatch={3}
|
||||||
windowSize={5}
|
windowSize={5}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
|
contentInset={{
|
||||||
|
left: sizes.padding.horizontal,
|
||||||
|
right: sizes.padding.horizontal,
|
||||||
|
}}
|
||||||
|
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
paddingHorizontal: SCALE_PADDING,
|
|
||||||
}}
|
}}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
<View
|
<View
|
||||||
@@ -475,8 +361,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
onPress={handleSeeAllPress}
|
onPress={handleSeeAllPress}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onFocus={handleSeeAllFocus}
|
typography={typography}
|
||||||
onBlur={handleItemBlur}
|
posterSizes={posterSizes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled: enableLazyLoading ? isInView : true,
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,7 +214,6 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,43 +11,19 @@ import { FlatList, View, type ViewProps } from "react-native";
|
|||||||
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
import MoviePoster, {
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
TV_POSTER_WIDTH,
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
} from "@/components/posters/MoviePoster.tv";
|
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||||
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||||
|
|
||||||
const ITEM_GAP = 16;
|
|
||||||
const SCALE_PADDING = 20;
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|
||||||
return (
|
|
||||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface WatchlistSectionProps extends ViewProps {
|
interface WatchlistSectionProps extends ViewProps {
|
||||||
watchlist: StreamystatsWatchlist;
|
watchlist: StreamystatsWatchlist;
|
||||||
jellyfinServerId: string;
|
jellyfinServerId: string;
|
||||||
@@ -60,10 +36,15 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
onItemFocus,
|
onItemFocus,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
|
const ITEM_GAP = sizes.gaps.item;
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
@@ -108,8 +89,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
Boolean(settings?.streamyStatsServerUrl) &&
|
Boolean(settings?.streamyStatsServerUrl) &&
|
||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnMount: false,
|
refetchInterval: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,30 +104,35 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
|
|
||||||
const getItemLayout = useCallback(
|
const getItemLayout = useCallback(
|
||||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
length: TV_POSTER_WIDTH + ITEM_GAP,
|
length: posterSizes.poster + ITEM_GAP,
|
||||||
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
offset: (posterSizes.poster + ITEM_GAP) * index,
|
||||||
index,
|
index,
|
||||||
}),
|
}),
|
||||||
[],
|
[posterSizes.poster, ITEM_GAP],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: BaseItemDto }) => {
|
({ item }: { item: BaseItemDto }) => {
|
||||||
return (
|
return (
|
||||||
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
<View style={{ marginRight: ITEM_GAP }}>
|
||||||
<TVFocusablePoster
|
<TVPosterCard
|
||||||
|
item={item}
|
||||||
|
orientation='vertical'
|
||||||
onPress={() => handleItemPress(item)}
|
onPress={() => handleItemPress(item)}
|
||||||
|
onLongPress={() => showItemActions(item)}
|
||||||
onFocus={() => onItemFocus?.(item)}
|
onFocus={() => onItemFocus?.(item)}
|
||||||
hasTVPreferredFocus={false}
|
width={posterSizes.poster}
|
||||||
>
|
/>
|
||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
|
||||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
|
||||||
</TVFocusablePoster>
|
|
||||||
<TVItemCardText item={item} />
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleItemPress, onItemFocus],
|
[
|
||||||
|
ITEM_GAP,
|
||||||
|
posterSizes.poster,
|
||||||
|
handleItemPress,
|
||||||
|
showItemActions,
|
||||||
|
onItemFocus,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isLoading && (!items || items.length === 0)) return null;
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
@@ -155,11 +141,12 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
<View style={{ overflow: "visible" }} {...props}>
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 20,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.horizontal,
|
||||||
|
letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{watchlist.name}
|
{watchlist.name}
|
||||||
@@ -175,11 +162,11 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
<View key={i} style={{ width: posterSizes.poster }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
width: TV_POSTER_WIDTH,
|
width: posterSizes.poster,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -201,9 +188,13 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
|
contentInset={{
|
||||||
|
left: sizes.padding.horizontal,
|
||||||
|
right: sizes.padding.horizontal,
|
||||||
|
}}
|
||||||
|
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
paddingHorizontal: SCALE_PADDING,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -219,6 +210,9 @@ interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
|||||||
export const StreamystatsPromotedWatchlists: React.FC<
|
export const StreamystatsPromotedWatchlists: React.FC<
|
||||||
StreamystatsPromotedWatchlistsProps
|
StreamystatsPromotedWatchlistsProps
|
||||||
> = ({ enabled = true, onItemFocus, ...props }) => {
|
> = ({ enabled = true, onItemFocus, ...props }) => {
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
|
const ITEM_GAP = sizes.gaps.item;
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
@@ -278,8 +272,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnMount: false,
|
refetchInterval: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,11 +303,11 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
<View key={i} style={{ width: posterSizes.poster }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
width: TV_POSTER_WIDTH,
|
width: posterSizes.poster,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +135,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
enabled:
|
enabled:
|
||||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,21 +11,16 @@ import { FlatList, View, type ViewProps } from "react-native";
|
|||||||
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
import MoviePoster, {
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
TV_POSTER_WIDTH,
|
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||||
} from "@/components/posters/MoviePoster.tv";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||||
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
|
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
|
||||||
|
|
||||||
const ITEM_GAP = 16;
|
|
||||||
const SCALE_PADDING = 20;
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title: string;
|
title: string;
|
||||||
type: "Movie" | "Series";
|
type: "Movie" | "Series";
|
||||||
@@ -34,28 +29,6 @@ interface Props extends ViewProps {
|
|||||||
onItemFocus?: (item: BaseItemDto) => void;
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|
||||||
return (
|
|
||||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const StreamystatsRecommendations: React.FC<Props> = ({
|
export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
@@ -64,10 +37,13 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
onItemFocus,
|
onItemFocus,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
@@ -133,8 +109,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnMount: false,
|
refetchInterval: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,8 +142,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
enabled:
|
enabled:
|
||||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnMount: false,
|
refetchInterval: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,30 +160,29 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
|
|
||||||
const getItemLayout = useCallback(
|
const getItemLayout = useCallback(
|
||||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
length: TV_POSTER_WIDTH + ITEM_GAP,
|
length: sizes.posters.poster + sizes.gaps.item,
|
||||||
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
offset: (sizes.posters.poster + sizes.gaps.item) * index,
|
||||||
index,
|
index,
|
||||||
}),
|
}),
|
||||||
[],
|
[sizes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: BaseItemDto }) => {
|
({ item }: { item: BaseItemDto }) => {
|
||||||
return (
|
return (
|
||||||
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
<View style={{ marginRight: sizes.gaps.item }}>
|
||||||
<TVFocusablePoster
|
<TVPosterCard
|
||||||
|
item={item}
|
||||||
|
orientation='vertical'
|
||||||
onPress={() => handleItemPress(item)}
|
onPress={() => handleItemPress(item)}
|
||||||
|
onLongPress={() => showItemActions(item)}
|
||||||
onFocus={() => onItemFocus?.(item)}
|
onFocus={() => onItemFocus?.(item)}
|
||||||
hasTVPreferredFocus={false}
|
width={sizes.posters.poster}
|
||||||
>
|
/>
|
||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
|
||||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
|
||||||
</TVFocusablePoster>
|
|
||||||
<TVItemCardText item={item} />
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleItemPress, onItemFocus],
|
[sizes, handleItemPress, showItemActions, onItemFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!streamyStatsEnabled) return null;
|
if (!streamyStatsEnabled) return null;
|
||||||
@@ -218,11 +193,12 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
<View style={{ overflow: "visible" }} {...props}>
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 20,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.horizontal,
|
||||||
|
letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -232,17 +208,17 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: ITEM_GAP,
|
gap: sizes.gaps.item,
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
<View key={i} style={{ width: sizes.posters.poster }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
width: TV_POSTER_WIDTH,
|
width: sizes.posters.poster,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -264,9 +240,13 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
|
contentInset={{
|
||||||
|
left: sizes.padding.horizontal,
|
||||||
|
right: sizes.padding.horizontal,
|
||||||
|
}}
|
||||||
|
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
paddingHorizontal: SCALE_PADDING,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
651
components/home/TVHeroCarousel.tsx
Normal file
651
components/home/TVHeroCarousel.tsx
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Easing,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
interface TVHeroCarouselProps {
|
||||||
|
items: BaseItemDto[];
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
onItemLongPress?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroCardProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
isFirst: boolean;
|
||||||
|
sizes: ScaledTVSizes;
|
||||||
|
onFocus: (item: BaseItemDto) => void;
|
||||||
|
onPress: (item: BaseItemDto) => void;
|
||||||
|
onLongPress?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||||
|
({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
// Check if glass effect is available (tvOS 26+)
|
||||||
|
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
||||||
|
|
||||||
|
const posterUrl = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
// For episodes, always use series thumb
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
if (item.ParentThumbImageTag) {
|
||||||
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
if (item.SeriesId) {
|
||||||
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-episodes, use item's own thumb/primary
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
|
||||||
|
}
|
||||||
|
if (item.ImageTags?.Primary) {
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const animateTo = useCallback(
|
||||||
|
(value: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: value,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(),
|
||||||
|
[scale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(sizes.animation.focusScale);
|
||||||
|
onFocus(item);
|
||||||
|
}, [animateTo, onFocus, item, sizes.animation.focusScale]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}, [animateTo]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onPress(item);
|
||||||
|
}, [onPress, item]);
|
||||||
|
|
||||||
|
const handleLongPress = useCallback(() => {
|
||||||
|
onLongPress?.(item);
|
||||||
|
}, [onLongPress, item]);
|
||||||
|
|
||||||
|
// Use glass poster for tvOS 26+
|
||||||
|
if (useGlass && posterUrl) {
|
||||||
|
const progress = item.UserData?.PlayedPercentage || 0;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={handleLongPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
style={{ marginRight: sizes.gaps.item }}
|
||||||
|
>
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={posterUrl}
|
||||||
|
aspectRatio={16 / 9}
|
||||||
|
cornerRadius={24}
|
||||||
|
progress={progress}
|
||||||
|
showWatchedIndicator={false}
|
||||||
|
isFocused={focused}
|
||||||
|
width={sizes.posters.episode}
|
||||||
|
style={{ width: sizes.posters.episode }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for non-tvOS or older tvOS
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={handleLongPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
style={{ marginRight: sizes.gaps.item }}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: sizes.posters.episode,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
transform: [{ scale }],
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#FFFFFF" : "transparent",
|
||||||
|
shadowColor: "#FFFFFF",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='film-outline'
|
||||||
|
size={48}
|
||||||
|
color='rgba(255,255,255,0.3)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ProgressBar item={item} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounce delay to prevent rapid backdrop changes when navigating fast
|
||||||
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||||
|
items,
|
||||||
|
onItemFocus,
|
||||||
|
onItemLongPress,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const _insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Active item for featured display (debounced)
|
||||||
|
const [activeItem, setActiveItem] = useState<BaseItemDto | null>(
|
||||||
|
items[0] || null,
|
||||||
|
);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Cleanup debounce timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Crossfade animation state
|
||||||
|
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
|
||||||
|
const [layer0Url, setLayer0Url] = useState<string | null>(null);
|
||||||
|
const [layer1Url, setLayer1Url] = useState<string | null>(null);
|
||||||
|
const layer0Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const layer1Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// Get backdrop URL for active item
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
if (!activeItem) return null;
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: activeItem,
|
||||||
|
quality: 90,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, activeItem]);
|
||||||
|
|
||||||
|
// Get logo URL for active item
|
||||||
|
const logoUrl = useMemo(() => {
|
||||||
|
if (!activeItem) return null;
|
||||||
|
return getLogoImageUrlById({ api, item: activeItem });
|
||||||
|
}, [api, activeItem]);
|
||||||
|
|
||||||
|
// Crossfade effect for backdrop
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backdropUrl) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const performCrossfade = async () => {
|
||||||
|
try {
|
||||||
|
await Image.prefetch(backdropUrl);
|
||||||
|
} catch {
|
||||||
|
// Continue even if prefetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const incomingLayer = activeLayer === 0 ? 1 : 0;
|
||||||
|
const incomingOpacity =
|
||||||
|
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
|
||||||
|
const outgoingOpacity =
|
||||||
|
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
|
||||||
|
|
||||||
|
if (incomingLayer === 0) {
|
||||||
|
setLayer0Url(backdropUrl);
|
||||||
|
} else {
|
||||||
|
setLayer1Url(backdropUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(incomingOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(outgoingOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setActiveLayer(incomingLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
performCrossfade();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [backdropUrl]);
|
||||||
|
|
||||||
|
// Handle card focus with debounce
|
||||||
|
const handleCardFocus = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
// Clear any pending debounce timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
// Set new timer to update active item after debounce delay
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setActiveItem(item);
|
||||||
|
onItemFocus?.(item);
|
||||||
|
}, BACKDROP_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
[onItemFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle card press - navigate to item
|
||||||
|
const handleCardPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get metadata for active item
|
||||||
|
const year = activeItem?.ProductionYear;
|
||||||
|
const duration = activeItem?.RunTimeTicks
|
||||||
|
? runtimeTicksToMinutes(activeItem.RunTimeTicks)
|
||||||
|
: null;
|
||||||
|
const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||||
|
const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0;
|
||||||
|
|
||||||
|
// Get display title
|
||||||
|
const displayTitle = useMemo(() => {
|
||||||
|
if (!activeItem) return "";
|
||||||
|
if (activeItem.Type === "Episode") {
|
||||||
|
return activeItem.SeriesName || activeItem.Name || "";
|
||||||
|
}
|
||||||
|
return activeItem.Name || "";
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// Get subtitle for episodes
|
||||||
|
const episodeSubtitle = useMemo(() => {
|
||||||
|
if (!activeItem || activeItem.Type !== "Episode") return null;
|
||||||
|
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// Memoize hero items to prevent re-renders
|
||||||
|
const heroItems = useMemo(() => items.slice(0, 8), [items]);
|
||||||
|
|
||||||
|
// Memoize renderItem for FlatList
|
||||||
|
const renderHeroCard = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<HeroCard
|
||||||
|
item={item}
|
||||||
|
isFirst={index === 0}
|
||||||
|
sizes={sizes}
|
||||||
|
onFocus={handleCardFocus}
|
||||||
|
onPress={handleCardPress}
|
||||||
|
onLongPress={onItemLongPress}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[handleCardFocus, handleCardPress, onItemLongPress, sizes],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize keyExtractor
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ height: heroHeight, width: "100%" }}>
|
||||||
|
{/* Backdrop layers with crossfade */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Layer 0 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer0Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer0Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer0Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Layer 1 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer1Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer1Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer1Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Gradient overlays */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.5)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.4)", "transparent"]}
|
||||||
|
locations={[0, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: "40%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Horizontal gradient for left side text contrast */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.9)", "rgba(0,0,0,0.6)", "transparent"]}
|
||||||
|
locations={[0, 0.5, 0.85]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content overlay - text elements with padding */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: sizes.padding.horizontal,
|
||||||
|
right: sizes.padding.horizontal,
|
||||||
|
bottom:
|
||||||
|
40 + sizes.posters.episode * (9 / 16) + sizes.gaps.small * 2 + 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo or Title */}
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logoUrl }}
|
||||||
|
style={{
|
||||||
|
height: 100,
|
||||||
|
width: SCREEN_WIDTH * 0.35,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
contentPosition='left'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.display,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Episode subtitle */}
|
||||||
|
{episodeSubtitle && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{episodeSubtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{activeItem?.Overview && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
marginBottom: 16,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.5,
|
||||||
|
lineHeight: typography.body * 1.4,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{activeItem.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata badges */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{duration && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{duration}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{activeItem?.OfficialRating && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255,255,255,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeItem.OfficialRating}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{hasProgress && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 60,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.3)",
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${playedPercent}%`,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(playedPercent)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Thumbnail carousel - edge-to-edge */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={heroItems}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentInset={{
|
||||||
|
left: sizes.padding.horizontal,
|
||||||
|
right: sizes.padding.horizontal,
|
||||||
|
}}
|
||||||
|
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
|
contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
|
||||||
|
renderItem={renderHeroCard}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
initialNumToRender={8}
|
||||||
|
maxToRenderPerBatch={8}
|
||||||
|
windowSize={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
type DiscoverEndpoint,
|
type DiscoverEndpoint,
|
||||||
@@ -33,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
item,
|
item,
|
||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
@@ -130,7 +131,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -142,7 +143,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
{year && (
|
{year && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -164,6 +165,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
slide,
|
slide,
|
||||||
isFirstSlide = false,
|
isFirstSlide = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
@@ -232,7 +234,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { Loader } from "@/components/Loader";
|
|||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
import { TVButton } from "@/components/tv";
|
import { TVButton } from "@/components/tv";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||||
@@ -68,6 +68,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -158,6 +159,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TVJellyseerrPage: React.FC = () => {
|
export const TVJellyseerrPage: React.FC = () => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -552,7 +554,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.display,
|
fontSize: typography.display,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
@@ -566,7 +568,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
{/* Year */}
|
{/* Year */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "rgba(255,255,255,0.7)",
|
color: "rgba(255,255,255,0.7)",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
@@ -601,7 +603,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
lineHeight: 32,
|
lineHeight: 32,
|
||||||
}}
|
}}
|
||||||
@@ -636,7 +638,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
@@ -663,7 +665,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -698,7 +700,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -732,7 +734,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -757,7 +759,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
|
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -776,7 +778,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -794,7 +796,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -813,7 +815,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
<View style={{ marginTop: 24 }}>
|
<View style={{ marginTop: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVButton, TVOptionSelector } from "@/components/tv";
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type {
|
import type {
|
||||||
QualityProfile,
|
QualityProfile,
|
||||||
@@ -51,6 +51,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onRequested,
|
onRequested,
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
@@ -389,7 +390,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -399,7 +400,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
@@ -473,7 +474,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
interface TVRequestOptionRowProps {
|
interface TVRequestOptionRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,6 +20,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
|||||||
hasTVPreferredFocus = false,
|
hasTVPreferredFocus = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({
|
useTVFocusAnimation({
|
||||||
scaleAmount: 1.02,
|
scaleAmount: 1.02,
|
||||||
@@ -56,7 +57,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -65,7 +66,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
|||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { Animated, Pressable, ScrollView, View } from "react-native";
|
import { Animated, Pressable, ScrollView, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
interface ToggleItem {
|
interface ToggleItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,6 +21,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({
|
useTVFocusAnimation({
|
||||||
scaleAmount: 1.08,
|
scaleAmount: 1.08,
|
||||||
@@ -57,7 +58,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: focused ? "#000" : "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: item.selected || focused ? "600" : "400",
|
fontWeight: item.selected || focused ? "600" : "400",
|
||||||
}}
|
}}
|
||||||
@@ -82,13 +83,14 @@ export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Animated, Easing, FlatList, Pressable, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -55,6 +56,7 @@ const TVLibraryRow: React.FC<{
|
|||||||
}> = ({ library, isFirst, onPress }) => {
|
}> = ({ library, isFirst, onPress }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
const opacity = useRef(new Animated.Value(0.7)).current;
|
const opacity = useRef(new Animated.Value(0.7)).current;
|
||||||
@@ -101,6 +103,8 @@ const TVLibraryRow: React.FC<{
|
|||||||
return t("library.item_types.series");
|
return t("library.item_types.series");
|
||||||
if (library.CollectionType === "boxsets")
|
if (library.CollectionType === "boxsets")
|
||||||
return t("library.item_types.boxsets");
|
return t("library.item_types.boxsets");
|
||||||
|
if (library.CollectionType === "playlists")
|
||||||
|
return t("library.item_types.playlists");
|
||||||
if (library.CollectionType === "music")
|
if (library.CollectionType === "music")
|
||||||
return t("library.item_types.items");
|
return t("library.item_types.items");
|
||||||
return t("library.item_types.items");
|
return t("library.item_types.items");
|
||||||
@@ -190,7 +194,7 @@ const TVLibraryRow: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 32,
|
fontSize: typography.heading,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
textShadowColor: "rgba(0,0,0,0.8)",
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
@@ -203,7 +207,7 @@ const TVLibraryRow: React.FC<{
|
|||||||
{library.itemCount !== undefined && (
|
{library.itemCount !== undefined && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
fontSize: typography.body,
|
||||||
color: "rgba(255,255,255,0.7)",
|
color: "rgba(255,255,255,0.7)",
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
textShadowColor: "rgba(0,0,0,0.8)",
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
@@ -237,6 +241,7 @@ export const TVLibraries: React.FC = () => {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
const { data: userViews, isLoading: viewsLoading } = useQuery({
|
const { data: userViews, isLoading: viewsLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
@@ -255,8 +260,7 @@ export const TVLibraries: React.FC = () => {
|
|||||||
userViews
|
userViews
|
||||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
.filter((l) => l.CollectionType !== "books")
|
.filter((l) => l.CollectionType !== "books")
|
||||||
.filter((l) => l.CollectionType !== "music")
|
.filter((l) => l.CollectionType !== "music") || [],
|
||||||
.filter((l) => l.CollectionType !== "playlists") || [],
|
|
||||||
[userViews, settings?.hiddenLibraries],
|
[userViews, settings?.hiddenLibraries],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -270,6 +274,10 @@ export const TVLibraries: React.FC = () => {
|
|||||||
if (library.CollectionType === "movies") itemType = "Movie";
|
if (library.CollectionType === "movies") itemType = "Movie";
|
||||||
else if (library.CollectionType === "tvshows") itemType = "Series";
|
else if (library.CollectionType === "tvshows") itemType = "Series";
|
||||||
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
|
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
|
||||||
|
else if (library.CollectionType === "playlists")
|
||||||
|
itemType = "Playlist";
|
||||||
|
|
||||||
|
const isPlaylistsLib = library.CollectionType === "playlists";
|
||||||
|
|
||||||
// Fetch count
|
// Fetch count
|
||||||
const countResponse = await getItemsApi(api!).getItems({
|
const countResponse = await getItemsApi(api!).getItems({
|
||||||
@@ -278,6 +286,7 @@ export const TVLibraries: React.FC = () => {
|
|||||||
recursive: true,
|
recursive: true,
|
||||||
limit: 0,
|
limit: 0,
|
||||||
includeItemTypes: itemType ? [itemType as any] : undefined,
|
includeItemTypes: itemType ? [itemType as any] : undefined,
|
||||||
|
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch preview items with backdrops
|
// Fetch preview items with backdrops
|
||||||
@@ -289,6 +298,7 @@ export const TVLibraries: React.FC = () => {
|
|||||||
sortBy: ["Random"],
|
sortBy: ["Random"],
|
||||||
includeItemTypes: itemType ? [itemType as any] : undefined,
|
includeItemTypes: itemType ? [itemType as any] : undefined,
|
||||||
imageTypes: ["Backdrop"],
|
imageTypes: ["Backdrop"],
|
||||||
|
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -306,6 +316,10 @@ export const TVLibraries: React.FC = () => {
|
|||||||
|
|
||||||
const handleLibraryPress = useCallback(
|
const handleLibraryPress = useCallback(
|
||||||
(library: BaseItemDto) => {
|
(library: BaseItemDto) => {
|
||||||
|
if (library.CollectionType === "livetv") {
|
||||||
|
router.push("/(auth)/(tabs)/(libraries)/livetv/programs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (library.CollectionType === "music") {
|
if (library.CollectionType === "music") {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
|
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
|
||||||
@@ -360,7 +374,7 @@ export const TVLibraries: React.FC = () => {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 20, color: "#737373" }}>
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||||
{t("library.no_libraries_found")}
|
{t("library.no_libraries_found")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -148,7 +150,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 22,
|
fontSize: typography.body,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -160,7 +162,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
|||||||
{itemsCount !== undefined && (
|
{itemsCount !== undefined && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
}}
|
}}
|
||||||
|
|||||||
183
components/livetv/TVChannelCard.tsx
Normal file
183
components/livetv/TVChannelCard.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Image, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
interface TVChannelCardProps {
|
||||||
|
channel: BaseItemDto;
|
||||||
|
api: Api | null;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_WIDTH = 200;
|
||||||
|
const CARD_HEIGHT = 160;
|
||||||
|
|
||||||
|
export const TVChannelCard: React.FC<TVChannelCardProps> = ({
|
||||||
|
channel,
|
||||||
|
api,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.05,
|
||||||
|
duration: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageUrl = getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item: channel,
|
||||||
|
quality: 80,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
style={styles.pressable}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
backgroundColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.08)",
|
||||||
|
borderColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
focused && styles.focusedShadow,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Channel logo or number */}
|
||||||
|
<View style={styles.logoContainer}>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={styles.logo}
|
||||||
|
resizeMode='contain'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.numberFallback,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#E5E5E5"
|
||||||
|
: "rgba(255, 255, 255, 0.15)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.numberText,
|
||||||
|
{
|
||||||
|
fontSize: typography.title,
|
||||||
|
color: focused ? "#000000" : "#FFFFFF",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{channel.ChannelNumber || "?"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Channel name */}
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={[
|
||||||
|
styles.channelName,
|
||||||
|
{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#000000" : "#FFFFFF",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{channel.Name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Channel number (if name is shown) */}
|
||||||
|
{channel.ChannelNumber && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
styles.channelNumber,
|
||||||
|
{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused
|
||||||
|
? "rgba(0, 0, 0, 0.6)"
|
||||||
|
: "rgba(255, 255, 255, 0.5)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Ch. {channel.ChannelNumber}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pressable: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
focusedShadow: {
|
||||||
|
shadowColor: "#FFFFFF",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 12,
|
||||||
|
},
|
||||||
|
logoContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 60,
|
||||||
|
marginBottom: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
numberFallback: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 30,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
numberText: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
channelName: {
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
channelNumber: {
|
||||||
|
fontWeight: "400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CARD_WIDTH, CARD_HEIGHT };
|
||||||
136
components/livetv/TVChannelsGrid.tsx
Normal file
136
components/livetv/TVChannelsGrid.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { TVChannelCard } from "./TVChannelCard";
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
|
const GRID_GAP = 16;
|
||||||
|
|
||||||
|
export const TVChannelsGrid: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
// Fetch all channels
|
||||||
|
const { data: channelsData, isLoading } = useQuery({
|
||||||
|
queryKey: ["livetv", "channels-grid", "all"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return null;
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvChannels({
|
||||||
|
enableFavoriteSorting: true,
|
||||||
|
userId: user.Id,
|
||||||
|
addCurrentProgram: false,
|
||||||
|
enableUserData: false,
|
||||||
|
enableImageTypes: ["Primary"],
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const channels = channelsData?.Items ?? [];
|
||||||
|
|
||||||
|
const handleChannelPress = useCallback(
|
||||||
|
(channelId: string | undefined) => {
|
||||||
|
if (channelId) {
|
||||||
|
// Navigate directly to the player to start the channel
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: channelId,
|
||||||
|
audioIndex: "",
|
||||||
|
subtitleIndex: "",
|
||||||
|
mediaSourceId: "",
|
||||||
|
bitrateValue: "",
|
||||||
|
});
|
||||||
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size='large' color='#FFFFFF' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channels.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={[styles.emptyText, { fontSize: typography.body }]}>
|
||||||
|
{t("live_tv.no_channels")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.contentContainer,
|
||||||
|
{
|
||||||
|
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{channels.map((channel, index) => (
|
||||||
|
<TVChannelCard
|
||||||
|
key={channel.Id ?? index}
|
||||||
|
channel={channel}
|
||||||
|
api={api}
|
||||||
|
onPress={() => handleChannelPress(channel.Id)}
|
||||||
|
// No hasTVPreferredFocus - tab buttons handle initial focus
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
paddingTop: 24,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
gap: GRID_GAP,
|
||||||
|
overflow: "visible",
|
||||||
|
paddingVertical: 10, // Extra padding for focus scale animation
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
},
|
||||||
|
});
|
||||||
146
components/livetv/TVGuideChannelRow.tsx
Normal file
146
components/livetv/TVGuideChannelRow.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import { TVGuideProgramCell } from "./TVGuideProgramCell";
|
||||||
|
|
||||||
|
interface TVGuideChannelRowProps {
|
||||||
|
programs: BaseItemDto[];
|
||||||
|
baseTime: Date;
|
||||||
|
pixelsPerHour: number;
|
||||||
|
minProgramWidth: number;
|
||||||
|
hoursToShow: number;
|
||||||
|
onProgramPress: (program: BaseItemDto) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
firstProgramRefSetter?: (ref: View | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVGuideChannelRow: React.FC<TVGuideChannelRowProps> = ({
|
||||||
|
programs,
|
||||||
|
baseTime,
|
||||||
|
pixelsPerHour,
|
||||||
|
minProgramWidth,
|
||||||
|
hoursToShow,
|
||||||
|
onProgramPress,
|
||||||
|
disabled = false,
|
||||||
|
firstProgramRefSetter,
|
||||||
|
}) => {
|
||||||
|
const isCurrentlyAiring = (program: BaseItemDto): boolean => {
|
||||||
|
if (!program.StartDate || !program.EndDate) return false;
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(program.StartDate);
|
||||||
|
const end = new Date(program.EndDate);
|
||||||
|
return now >= start && now <= end;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeOffset = (startDate: string): number => {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const diffMinutes = (start.getTime() - baseTime.getTime()) / 60000;
|
||||||
|
return Math.max(0, (diffMinutes / 60) * pixelsPerHour);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter programs for this channel and within the time window
|
||||||
|
const filteredPrograms = useMemo(() => {
|
||||||
|
const endTime = new Date(baseTime.getTime() + hoursToShow * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return programs
|
||||||
|
.filter((p) => {
|
||||||
|
if (!p.StartDate || !p.EndDate) return false;
|
||||||
|
const start = new Date(p.StartDate);
|
||||||
|
const end = new Date(p.EndDate);
|
||||||
|
// Program overlaps with our time window
|
||||||
|
return end > baseTime && start < endTime;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.StartDate || 0);
|
||||||
|
const dateB = new Date(b.StartDate || 0);
|
||||||
|
return dateA.getTime() - dateB.getTime();
|
||||||
|
});
|
||||||
|
}, [programs, baseTime, hoursToShow]);
|
||||||
|
|
||||||
|
// Calculate program cells with positions (absolute positioning)
|
||||||
|
const programCells = useMemo(() => {
|
||||||
|
return filteredPrograms.map((program) => {
|
||||||
|
if (!program.StartDate || !program.EndDate) {
|
||||||
|
return { program, width: minProgramWidth, left: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp the start time to baseTime if program started earlier
|
||||||
|
const programStart = new Date(program.StartDate);
|
||||||
|
const effectiveStart = programStart < baseTime ? baseTime : programStart;
|
||||||
|
|
||||||
|
// Clamp the end time to the window end
|
||||||
|
const windowEnd = new Date(
|
||||||
|
baseTime.getTime() + hoursToShow * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
const programEnd = new Date(program.EndDate);
|
||||||
|
const effectiveEnd = programEnd > windowEnd ? windowEnd : programEnd;
|
||||||
|
|
||||||
|
const durationMinutes =
|
||||||
|
(effectiveEnd.getTime() - effectiveStart.getTime()) / 60000;
|
||||||
|
const width = Math.max(
|
||||||
|
(durationMinutes / 60) * pixelsPerHour - 4,
|
||||||
|
minProgramWidth,
|
||||||
|
); // -4 for gap
|
||||||
|
|
||||||
|
const left = getTimeOffset(effectiveStart.toISOString());
|
||||||
|
|
||||||
|
return {
|
||||||
|
program,
|
||||||
|
width,
|
||||||
|
left,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [filteredPrograms, baseTime, pixelsPerHour, minProgramWidth, hoursToShow]);
|
||||||
|
|
||||||
|
const totalWidth = hoursToShow * pixelsPerHour;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { width: totalWidth }]}>
|
||||||
|
{programCells.map(({ program, width, left }, index) => (
|
||||||
|
<View
|
||||||
|
key={program.Id || index}
|
||||||
|
style={[styles.programCellWrapper, { left, width }]}
|
||||||
|
>
|
||||||
|
<TVGuideProgramCell
|
||||||
|
program={program}
|
||||||
|
width={width}
|
||||||
|
isCurrentlyAiring={isCurrentlyAiring(program)}
|
||||||
|
onPress={() => onProgramPress(program)}
|
||||||
|
disabled={disabled}
|
||||||
|
refSetter={index === 0 ? firstProgramRefSetter : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{programCells.length === 0 && (
|
||||||
|
<View style={[styles.noPrograms, { width: totalWidth - 8 }]}>
|
||||||
|
{/* Empty row indicator */}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
height: 80,
|
||||||
|
position: "relative",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(255, 255, 255, 0.2)",
|
||||||
|
backgroundColor: "rgba(20, 20, 20, 1)",
|
||||||
|
},
|
||||||
|
programCellWrapper: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
bottom: 4,
|
||||||
|
},
|
||||||
|
noPrograms: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 4,
|
||||||
|
top: 4,
|
||||||
|
bottom: 4,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
154
components/livetv/TVGuidePageNavigation.tsx
Normal file
154
components/livetv/TVGuidePageNavigation.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
interface TVGuidePageNavigationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
prevButtonRefSetter?: (ref: View | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavButtonProps {
|
||||||
|
onPress: () => void;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label: string;
|
||||||
|
isDisabled: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
refSetter?: (ref: View | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavButton: React.FC<NavButtonProps> = ({
|
||||||
|
onPress,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
isDisabled,
|
||||||
|
disabled = false,
|
||||||
|
refSetter,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.05,
|
||||||
|
duration: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const visuallyDisabled = isDisabled || disabled;
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (!visuallyDisabled) {
|
||||||
|
onPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={refSetter}
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.navButton,
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
|
||||||
|
opacity: visuallyDisabled ? 0.3 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={20}
|
||||||
|
color={focused ? "#000000" : "#FFFFFF"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.navButtonText,
|
||||||
|
{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#000000" : "#FFFFFF",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVGuidePageNavigation: React.FC<TVGuidePageNavigationProps> = ({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
disabled = false,
|
||||||
|
prevButtonRefSetter,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.buttonsContainer}>
|
||||||
|
<NavButton
|
||||||
|
onPress={onPrevious}
|
||||||
|
icon='chevron-back'
|
||||||
|
label={t("live_tv.previous")}
|
||||||
|
isDisabled={currentPage <= 1}
|
||||||
|
disabled={disabled}
|
||||||
|
refSetter={prevButtonRefSetter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NavButton
|
||||||
|
onPress={onNext}
|
||||||
|
icon='chevron-forward'
|
||||||
|
label={t("live_tv.next")}
|
||||||
|
isDisabled={currentPage >= totalPages}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.pageText, { fontSize: typography.callout }]}>
|
||||||
|
{t("live_tv.page_of", { current: currentPage, total: totalPages })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
buttonsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
navButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
navButtonText: {
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
pageText: {
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
},
|
||||||
|
});
|
||||||
148
components/livetv/TVGuideProgramCell.tsx
Normal file
148
components/livetv/TVGuideProgramCell.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
interface TVGuideProgramCellProps {
|
||||||
|
program: BaseItemDto;
|
||||||
|
width: number;
|
||||||
|
isCurrentlyAiring: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
refSetter?: (ref: View | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
||||||
|
program,
|
||||||
|
width,
|
||||||
|
isCurrentlyAiring,
|
||||||
|
onPress,
|
||||||
|
disabled = false,
|
||||||
|
refSetter,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||||
|
scaleAmount: 1,
|
||||||
|
duration: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatTime = (date: string | null | undefined) => {
|
||||||
|
if (!date) return "";
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={refSetter}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
style={{ width }}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#FFFFFF"
|
||||||
|
: isCurrentlyAiring
|
||||||
|
? "rgba(255, 255, 255, 0.15)"
|
||||||
|
: "rgba(255, 255, 255, 0.08)",
|
||||||
|
borderColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
focused && styles.focusedShadow,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* LIVE badge */}
|
||||||
|
{isCurrentlyAiring && (
|
||||||
|
<View style={styles.liveBadge}>
|
||||||
|
<Text
|
||||||
|
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||||
|
>
|
||||||
|
LIVE
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Program name */}
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={[
|
||||||
|
styles.programName,
|
||||||
|
{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#000000" : "#FFFFFF",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{program.Name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Time range */}
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
styles.timeText,
|
||||||
|
{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused
|
||||||
|
? "rgba(0, 0, 0, 0.6)"
|
||||||
|
: "rgba(255, 255, 255, 0.5)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formatTime(program.StartDate)} - {formatTime(program.EndDate)}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
height: 70,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
focusedShadow: {
|
||||||
|
shadowColor: "#FFFFFF",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 12,
|
||||||
|
},
|
||||||
|
liveBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
backgroundColor: "#EF4444",
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
zIndex: 10,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
liveBadgeText: {
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
programName: {
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
fontWeight: "400",
|
||||||
|
},
|
||||||
|
});
|
||||||
64
components/livetv/TVGuideTimeHeader.tsx
Normal file
64
components/livetv/TVGuideTimeHeader.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
interface TVGuideTimeHeaderProps {
|
||||||
|
baseTime: Date;
|
||||||
|
hoursToShow: number;
|
||||||
|
pixelsPerHour: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVGuideTimeHeader: React.FC<TVGuideTimeHeaderProps> = ({
|
||||||
|
baseTime,
|
||||||
|
hoursToShow,
|
||||||
|
pixelsPerHour,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
const hours: Date[] = [];
|
||||||
|
for (let i = 0; i < hoursToShow; i++) {
|
||||||
|
const hour = new Date(baseTime);
|
||||||
|
hour.setMinutes(0, 0, 0);
|
||||||
|
hour.setHours(baseTime.getHours() + i);
|
||||||
|
hours.push(hour);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatHour = (date: Date) => {
|
||||||
|
return date.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlurView intensity={40} tint='dark' style={styles.container}>
|
||||||
|
{hours.map((hour, index) => (
|
||||||
|
<View key={index} style={[styles.hourCell, { width: pixelsPerHour }]}>
|
||||||
|
<Text style={[styles.hourText, { fontSize: typography.callout }]}>
|
||||||
|
{formatHour(hour)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</BlurView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
height: 44,
|
||||||
|
},
|
||||||
|
hourCell: {
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingLeft: 12,
|
||||||
|
borderLeftWidth: 1,
|
||||||
|
borderLeftColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
hourText: {
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
});
|
||||||
433
components/livetv/TVLiveTVGuide.tsx
Normal file
433
components/livetv/TVLiveTVGuide.tsx
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
NativeScrollEvent,
|
||||||
|
NativeSyntheticEvent,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { TVGuideChannelRow } from "./TVGuideChannelRow";
|
||||||
|
import { TVGuidePageNavigation } from "./TVGuidePageNavigation";
|
||||||
|
import { TVGuideTimeHeader } from "./TVGuideTimeHeader";
|
||||||
|
|
||||||
|
// Design constants
|
||||||
|
const CHANNEL_COLUMN_WIDTH = 240;
|
||||||
|
const PIXELS_PER_HOUR = 250;
|
||||||
|
const ROW_HEIGHT = 80;
|
||||||
|
const TIME_HEADER_HEIGHT = 44;
|
||||||
|
const CHANNELS_PER_PAGE = 20;
|
||||||
|
const MIN_PROGRAM_WIDTH = 80;
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
|
|
||||||
|
// Channel label component
|
||||||
|
const ChannelLabel: React.FC<{
|
||||||
|
channel: BaseItemDto;
|
||||||
|
typography: ReturnType<typeof useScaledTVTypography>;
|
||||||
|
}> = ({ channel, typography }) => (
|
||||||
|
<View style={styles.channelLabel}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[styles.channelNumber, { fontSize: typography.callout }]}
|
||||||
|
>
|
||||||
|
{channel.ChannelNumber}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[styles.channelName, { fontSize: typography.callout }]}
|
||||||
|
>
|
||||||
|
{channel.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TVLiveTVGuide: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// Scroll refs for synchronization
|
||||||
|
const channelListRef = useRef<ScrollView>(null);
|
||||||
|
const mainVerticalRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
// Focus guide refs for bidirectional navigation
|
||||||
|
const [firstProgramRef, setFirstProgramRef] = useState<View | null>(null);
|
||||||
|
const [prevButtonRef, setPrevButtonRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
// Base time - start of current hour, end time - end of day
|
||||||
|
const [{ baseTime, endOfDay, hoursToShow }] = useState(() => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(0, 0, 0);
|
||||||
|
|
||||||
|
const endOfDayTime = new Date(now);
|
||||||
|
endOfDayTime.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const hoursUntilEndOfDay = Math.ceil(
|
||||||
|
(endOfDayTime.getTime() - now.getTime()) / (60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseTime: now,
|
||||||
|
endOfDay: endOfDayTime,
|
||||||
|
hoursToShow: Math.max(hoursUntilEndOfDay, 1), // At least 1 hour
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current time indicator position (relative to program grid start)
|
||||||
|
const [currentTimeOffset, setCurrentTimeOffset] = useState(0);
|
||||||
|
|
||||||
|
// Update current time indicator every minute
|
||||||
|
useEffect(() => {
|
||||||
|
const updateCurrentTime = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMinutes = (now.getTime() - baseTime.getTime()) / 60000;
|
||||||
|
const offset = (diffMinutes / 60) * PIXELS_PER_HOUR;
|
||||||
|
setCurrentTimeOffset(offset);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCurrentTime();
|
||||||
|
const interval = setInterval(updateCurrentTime, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [baseTime]);
|
||||||
|
|
||||||
|
// Sync vertical scroll between channel list and main grid
|
||||||
|
const handleVerticalScroll = useCallback(
|
||||||
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const offsetY = event.nativeEvent.contentOffset.y;
|
||||||
|
channelListRef.current?.scrollTo({ y: offsetY, animated: false });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch channels
|
||||||
|
const { data: channelsData, isLoading: isLoadingChannels } = useQuery({
|
||||||
|
queryKey: ["livetv", "tv-guide", "channels"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return null;
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvChannels({
|
||||||
|
enableFavoriteSorting: true,
|
||||||
|
userId: user.Id,
|
||||||
|
addCurrentProgram: false,
|
||||||
|
enableUserData: false,
|
||||||
|
enableImageTypes: ["Primary"],
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalChannels = channelsData?.TotalRecordCount ?? 0;
|
||||||
|
const totalPages = Math.ceil(totalChannels / CHANNELS_PER_PAGE);
|
||||||
|
const allChannels = channelsData?.Items ?? [];
|
||||||
|
|
||||||
|
// Get channels for current page
|
||||||
|
const paginatedChannels = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * CHANNELS_PER_PAGE;
|
||||||
|
return allChannels.slice(startIndex, startIndex + CHANNELS_PER_PAGE);
|
||||||
|
}, [allChannels, currentPage]);
|
||||||
|
|
||||||
|
const channelIds = useMemo(
|
||||||
|
() => paginatedChannels.map((c) => c.Id).filter(Boolean) as string[],
|
||||||
|
[paginatedChannels],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch programs for visible channels
|
||||||
|
const { data: programsData, isLoading: isLoadingPrograms } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"livetv",
|
||||||
|
"tv-guide",
|
||||||
|
"programs",
|
||||||
|
channelIds,
|
||||||
|
baseTime.toISOString(),
|
||||||
|
endOfDay.toISOString(),
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || channelIds.length === 0) return null;
|
||||||
|
const res = await getLiveTvApi(api).getPrograms({
|
||||||
|
getProgramsDto: {
|
||||||
|
MaxStartDate: endOfDay.toISOString(),
|
||||||
|
MinEndDate: baseTime.toISOString(),
|
||||||
|
ChannelIds: channelIds,
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
EnableImages: false,
|
||||||
|
SortBy: ["StartDate"],
|
||||||
|
EnableTotalRecordCount: false,
|
||||||
|
EnableUserData: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: channelIds.length > 0,
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const programs = programsData?.Items ?? [];
|
||||||
|
|
||||||
|
// Group programs by channel
|
||||||
|
const programsByChannel = useMemo(() => {
|
||||||
|
const grouped: Record<string, BaseItemDto[]> = {};
|
||||||
|
for (const program of programs) {
|
||||||
|
const channelId = program.ChannelId;
|
||||||
|
if (channelId) {
|
||||||
|
if (!grouped[channelId]) {
|
||||||
|
grouped[channelId] = [];
|
||||||
|
}
|
||||||
|
grouped[channelId].push(program);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [programs]);
|
||||||
|
|
||||||
|
const handleProgramPress = useCallback(
|
||||||
|
(program: BaseItemDto) => {
|
||||||
|
// Navigate to play the program/channel
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: program.Id ?? "",
|
||||||
|
audioIndex: "",
|
||||||
|
subtitleIndex: "",
|
||||||
|
mediaSourceId: "",
|
||||||
|
bitrateValue: "",
|
||||||
|
});
|
||||||
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePreviousPage = useCallback(() => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
setCurrentPage((p) => p - 1);
|
||||||
|
}
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
const handleNextPage = useCallback(() => {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
setCurrentPage((p) => p + 1);
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
const isLoading = isLoadingChannels;
|
||||||
|
const totalWidth = hoursToShow * PIXELS_PER_HOUR;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size='large' color='#FFFFFF' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paginatedChannels.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={[styles.emptyText, { fontSize: typography.body }]}>
|
||||||
|
{t("live_tv.no_programs")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Page Navigation */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<View style={{ paddingHorizontal: insets.left + HORIZONTAL_PADDING }}>
|
||||||
|
<TVGuidePageNavigation
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPrevious={handlePreviousPage}
|
||||||
|
onNext={handleNextPage}
|
||||||
|
prevButtonRefSetter={setPrevButtonRef}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bidirectional focus guides */}
|
||||||
|
{firstProgramRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[firstProgramRef]}
|
||||||
|
style={{ height: 1, width: "100%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{prevButtonRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[prevButtonRef]}
|
||||||
|
style={{ height: 1, width: "100%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main grid container */}
|
||||||
|
<View style={styles.gridWrapper}>
|
||||||
|
{/* Fixed channel column */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.channelColumn,
|
||||||
|
{
|
||||||
|
width: CHANNEL_COLUMN_WIDTH,
|
||||||
|
marginLeft: insets.left + HORIZONTAL_PADDING,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Spacer for time header */}
|
||||||
|
<View style={{ height: TIME_HEADER_HEIGHT }} />
|
||||||
|
|
||||||
|
{/* Channel labels - synced with main scroll */}
|
||||||
|
<ScrollView
|
||||||
|
ref={channelListRef}
|
||||||
|
scrollEnabled={false}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 60 }}
|
||||||
|
>
|
||||||
|
{paginatedChannels.map((channel, index) => (
|
||||||
|
<ChannelLabel
|
||||||
|
key={channel.Id ?? index}
|
||||||
|
channel={channel}
|
||||||
|
typography={typography}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Scrollable programs area */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.horizontalScroll}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ width: totalWidth, position: "relative" }}>
|
||||||
|
{/* Time header */}
|
||||||
|
<TVGuideTimeHeader
|
||||||
|
baseTime={baseTime}
|
||||||
|
hoursToShow={hoursToShow}
|
||||||
|
pixelsPerHour={PIXELS_PER_HOUR}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Programs grid - vertical scroll */}
|
||||||
|
<ScrollView
|
||||||
|
ref={mainVerticalRef}
|
||||||
|
onScroll={handleVerticalScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 60 }}
|
||||||
|
>
|
||||||
|
{paginatedChannels.map((channel, index) => {
|
||||||
|
const channelPrograms = channel.Id
|
||||||
|
? (programsByChannel[channel.Id] ?? [])
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<TVGuideChannelRow
|
||||||
|
key={channel.Id ?? index}
|
||||||
|
programs={channelPrograms}
|
||||||
|
baseTime={baseTime}
|
||||||
|
pixelsPerHour={PIXELS_PER_HOUR}
|
||||||
|
minProgramWidth={MIN_PROGRAM_WIDTH}
|
||||||
|
hoursToShow={hoursToShow}
|
||||||
|
onProgramPress={handleProgramPress}
|
||||||
|
firstProgramRefSetter={
|
||||||
|
index === 0 ? setFirstProgramRef : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Current time indicator */}
|
||||||
|
{currentTimeOffset > 0 && currentTimeOffset < totalWidth && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.currentTimeIndicator,
|
||||||
|
{
|
||||||
|
left: currentTimeOffset,
|
||||||
|
top: 0,
|
||||||
|
height:
|
||||||
|
TIME_HEADER_HEIGHT +
|
||||||
|
paginatedChannels.length * ROW_HEIGHT,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
},
|
||||||
|
gridWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
channelColumn: {
|
||||||
|
backgroundColor: "rgba(40, 40, 40, 1)",
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: "rgba(255, 255, 255, 0.2)",
|
||||||
|
},
|
||||||
|
channelLabel: {
|
||||||
|
height: ROW_HEIGHT,
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(255, 255, 255, 0.2)",
|
||||||
|
},
|
||||||
|
channelNumber: {
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontWeight: "400",
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
channelName: {
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
horizontalScroll: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
currentTimeIndicator: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 2,
|
||||||
|
backgroundColor: "#EF4444",
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
265
components/livetv/TVLiveTVPage.tsx
Normal file
265
components/livetv/TVLiveTVPage.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
|
import { TVChannelsGrid } from "@/components/livetv/TVChannelsGrid";
|
||||||
|
import { TVLiveTVGuide } from "@/components/livetv/TVLiveTVGuide";
|
||||||
|
import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder";
|
||||||
|
import { TVTabButton } from "@/components/tv/TVTabButton";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
|
const TOP_PADDING = 100;
|
||||||
|
const SECTION_GAP = 24;
|
||||||
|
|
||||||
|
type TabId =
|
||||||
|
| "programs"
|
||||||
|
| "guide"
|
||||||
|
| "channels"
|
||||||
|
| "recordings"
|
||||||
|
| "schedule"
|
||||||
|
| "series";
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: TabId;
|
||||||
|
labelKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: Tab[] = [
|
||||||
|
{ id: "programs", labelKey: "live_tv.tabs.programs" },
|
||||||
|
{ id: "guide", labelKey: "live_tv.tabs.guide" },
|
||||||
|
{ id: "channels", labelKey: "live_tv.tabs.channels" },
|
||||||
|
{ id: "recordings", labelKey: "live_tv.tabs.recordings" },
|
||||||
|
{ id: "schedule", labelKey: "live_tv.tabs.schedule" },
|
||||||
|
{ id: "series", labelKey: "live_tv.tabs.series" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TVLiveTVPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>("programs");
|
||||||
|
|
||||||
|
// Section configurations for Programs tab
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: t("live_tv.on_now"),
|
||||||
|
queryKey: ["livetv", "tv", "onNow"],
|
||||||
|
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
|
||||||
|
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||||
|
userId: user.Id,
|
||||||
|
isAiring: true,
|
||||||
|
limit: 24,
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||||
|
});
|
||||||
|
const items = res.data.Items || [];
|
||||||
|
return items.slice(pageParam, pageParam + 10);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("live_tv.shows"),
|
||||||
|
queryKey: ["livetv", "tv", "shows"],
|
||||||
|
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 24,
|
||||||
|
isMovie: false,
|
||||||
|
isSeries: true,
|
||||||
|
isSports: false,
|
||||||
|
isNews: false,
|
||||||
|
isKids: false,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
const items = res.data.Items || [];
|
||||||
|
return items.slice(pageParam, pageParam + 10);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("live_tv.movies"),
|
||||||
|
queryKey: ["livetv", "tv", "movies"],
|
||||||
|
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 24,
|
||||||
|
isMovie: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
const items = res.data.Items || [];
|
||||||
|
return items.slice(pageParam, pageParam + 10);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("live_tv.sports"),
|
||||||
|
queryKey: ["livetv", "tv", "sports"],
|
||||||
|
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 24,
|
||||||
|
isSports: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
const items = res.data.Items || [];
|
||||||
|
return items.slice(pageParam, pageParam + 10);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("live_tv.for_kids"),
|
||||||
|
queryKey: ["livetv", "tv", "kids"],
|
||||||
|
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 24,
|
||||||
|
isKids: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
const items = res.data.Items || [];
|
||||||
|
return items.slice(pageParam, pageParam + 10);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("live_tv.news"),
|
||||||
|
queryKey: ["livetv", "tv", "news"],
|
||||||
|
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 24,
|
||||||
|
isNews: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
const items = res.data.Items || [];
|
||||||
|
return items.slice(pageParam, pageParam + 10);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [api, user?.Id, t]);
|
||||||
|
|
||||||
|
const handleTabSelect = useCallback((tabId: TabId) => {
|
||||||
|
setActiveTab(tabId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderProgramsContent = () => (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
gap: SECTION_GAP,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
key={section.queryKey.join("-")}
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation='horizontal'
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={10}
|
||||||
|
enabled={true}
|
||||||
|
isFirstSection={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
if (activeTab === "programs") {
|
||||||
|
return renderProgramsContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === "guide") {
|
||||||
|
return <TVLiveTVGuide />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === "channels") {
|
||||||
|
return <TVChannelsGrid />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder for other tabs
|
||||||
|
const tab = TABS.find((t) => t.id === activeTab);
|
||||||
|
return <TVLiveTVPlaceholder tabName={t(tab?.labelKey || "")} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
{/* Header with Title and Tabs */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.display,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Live TV
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Tab Bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<TVTabButton
|
||||||
|
key={tab.id}
|
||||||
|
label={t(tab.labelKey)}
|
||||||
|
active={activeTab === tab.id}
|
||||||
|
onSelect={() => handleTabSelect(tab.id)}
|
||||||
|
hasTVPreferredFocus={activeTab === tab.id}
|
||||||
|
switchOnFocus={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<View style={{ flex: 1 }}>{renderTabContent()}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
components/livetv/TVLiveTVPlaceholder.tsx
Normal file
46
components/livetv/TVLiveTVPlaceholder.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
interface TVLiveTVPlaceholderProps {
|
||||||
|
tabName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVLiveTVPlaceholder: React.FC<TVLiveTVPlaceholderProps> = ({
|
||||||
|
tabName,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.title,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("live_tv.coming_soon")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -72,22 +72,24 @@ export const Login: React.FC = () => {
|
|||||||
password: string;
|
password: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
await setServer({
|
await setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (_username && _password) {
|
|
||||||
setCredentials({ username: _username, password: _password });
|
|
||||||
login(_username, _password);
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl]);
|
||||||
|
|
||||||
|
// Handle auto-login when api is ready and credentials are provided via URL params
|
||||||
|
useEffect(() => {
|
||||||
|
if (api?.basePath && _apiUrl && _username && _password) {
|
||||||
|
setCredentials({ username: _username, password: _password });
|
||||||
|
login(_username, _password);
|
||||||
|
}
|
||||||
|
}, [api?.basePath, _apiUrl, _username, _password]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import React, { useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Animated, Easing, Pressable, View } from "react-native";
|
import { Animated, Easing, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import type { SavedServerAccount } from "@/utils/secureCredentials";
|
import type { SavedServerAccount } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
interface TVAccountCardProps {
|
interface TVAccountCardProps {
|
||||||
@@ -85,7 +84,7 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
shadowColor: "#a855f7",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowRadius: 16,
|
shadowRadius: 16,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
@@ -143,7 +142,7 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Security Icon */}
|
{/* Security Icon */}
|
||||||
<Ionicons name={getSecurityIcon()} size={24} color={Colors.primary} />
|
<Ionicons name={getSecurityIcon()} size={24} color='#fff' />
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
82
components/login/TVAddIcon.tsx
Normal file
82
components/login/TVAddIcon.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
export interface TVAddIconProps {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVAddIcon = React.forwardRef<View, TVAddIconProps>(
|
||||||
|
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
alignItems: "center",
|
||||||
|
width: 160,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.5 : 0,
|
||||||
|
shadowRadius: focused ? 16 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
borderRadius: 70,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(255,255,255,0.15)"
|
||||||
|
: "rgba(255,255,255,0.05)",
|
||||||
|
marginBottom: 14,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
|
||||||
|
borderStyle: "dashed",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={56}
|
||||||
|
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
162
components/login/TVAddServerForm.tsx
Normal file
162
components/login/TVAddServerForm.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { TVInput } from "./TVInput";
|
||||||
|
|
||||||
|
interface TVAddServerFormProps {
|
||||||
|
onConnect: (url: string) => Promise<void>;
|
||||||
|
onBack: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVBackButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, label, disabled = false }) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.05 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
}}
|
||||||
|
style={{ alignSelf: "flex-start", marginBottom: 24 }}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-back'
|
||||||
|
size={28}
|
||||||
|
color={isFocused ? "#000" : "#fff"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: isFocused ? "#000" : "#fff",
|
||||||
|
fontSize: 20,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||||
|
onConnect,
|
||||||
|
onBack,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const [serverURL, setServerURL] = useState("");
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
if (serverURL.trim()) {
|
||||||
|
await onConnect(serverURL.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 60,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
paddingHorizontal: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Back Button */}
|
||||||
|
<TVBackButton
|
||||||
|
onPress={onBack}
|
||||||
|
label={t("common.back")}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Server URL Input */}
|
||||||
|
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||||
|
<TVInput
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
value={serverURL}
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
keyboardType='url'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
returnKeyType='done'
|
||||||
|
hasTVPreferredFocus
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Connect Button */}
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
onPress={handleConnect}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !serverURL.trim()}
|
||||||
|
color='white'
|
||||||
|
>
|
||||||
|
{t("server.connect_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hint text */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#6B7280",
|
||||||
|
textAlign: "left",
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
230
components/login/TVAddUserForm.tsx
Normal file
230
components/login/TVAddUserForm.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { TVInput } from "./TVInput";
|
||||||
|
import { TVSaveAccountToggle } from "./TVSaveAccountToggle";
|
||||||
|
|
||||||
|
interface TVAddUserFormProps {
|
||||||
|
serverName: string;
|
||||||
|
serverAddress: string;
|
||||||
|
onLogin: (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
saveAccount: boolean,
|
||||||
|
) => Promise<void>;
|
||||||
|
onQuickConnect: () => Promise<void>;
|
||||||
|
onBack: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVBackButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, label, disabled = false }) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.05 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
}}
|
||||||
|
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-back'
|
||||||
|
size={28}
|
||||||
|
color={isFocused ? "#000" : "#fff"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: isFocused ? "#000" : "#fff",
|
||||||
|
fontSize: 20,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
|
||||||
|
serverName,
|
||||||
|
serverAddress,
|
||||||
|
onLogin,
|
||||||
|
onQuickConnect,
|
||||||
|
onBack,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const [credentials, setCredentials] = useState({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (credentials.username.trim()) {
|
||||||
|
await onLogin(credentials.username, credentials.password, saveAccount);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 60,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
paddingHorizontal: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Back Button */}
|
||||||
|
<TVBackButton
|
||||||
|
onPress={onBack}
|
||||||
|
label={t("common.back")}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.title,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serverName ? (
|
||||||
|
<>
|
||||||
|
{`${t("login.login_to_title")} `}
|
||||||
|
<Text style={{ color: "#fff" }}>{serverName}</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("login.login_title")
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serverAddress}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Username Input */}
|
||||||
|
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||||
|
<TVInput
|
||||||
|
placeholder={t("login.username_placeholder")}
|
||||||
|
value={credentials.username}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials((prev) => ({ ...prev, username: text }))
|
||||||
|
}
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
textContentType='username'
|
||||||
|
returnKeyType='next'
|
||||||
|
hasTVPreferredFocus
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
|
||||||
|
<TVInput
|
||||||
|
placeholder={t("login.password_placeholder")}
|
||||||
|
value={credentials.password}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials((prev) => ({ ...prev, password: text }))
|
||||||
|
}
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='password'
|
||||||
|
returnKeyType='done'
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Account Toggle */}
|
||||||
|
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
||||||
|
<TVSaveAccountToggle
|
||||||
|
value={saveAccount}
|
||||||
|
onValueChange={setSaveAccount}
|
||||||
|
label={t("save_account.save_for_later")}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Login Button */}
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!credentials.username.trim() || loading}
|
||||||
|
color='white'
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quick Connect Button */}
|
||||||
|
<Button
|
||||||
|
onPress={onQuickConnect}
|
||||||
|
color='black'
|
||||||
|
className='bg-neutral-800 border border-neutral-700'
|
||||||
|
>
|
||||||
|
{t("login.quick_connect")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
82
components/login/TVBackIcon.tsx
Normal file
82
components/login/TVBackIcon.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
export interface TVBackIconProps {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVBackIcon = React.forwardRef<View, TVBackIconProps>(
|
||||||
|
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
alignItems: "center",
|
||||||
|
width: 160,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.5 : 0,
|
||||||
|
shadowRadius: focused ? 16 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
borderRadius: 70,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(255,255,255,0.15)"
|
||||||
|
: "rgba(255,255,255,0.05)",
|
||||||
|
marginBottom: 14,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
|
||||||
|
borderStyle: "dashed",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='arrow-back'
|
||||||
|
size={56}
|
||||||
|
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
type TextInputProps,
|
type TextInputProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { fontSize, size } from "react-native-responsive-sizes";
|
|
||||||
|
|
||||||
interface TVInputProps extends TextInputProps {
|
interface TVInputProps extends TextInputProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -59,20 +58,25 @@ export const TVInput: React.FC<TVInputProps> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
borderRadius: size(20),
|
borderRadius: 12,
|
||||||
borderWidth: size(6),
|
backgroundColor: isFocused
|
||||||
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
? "rgba(255,255,255,0.15)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={displayPlaceholder}
|
placeholder={displayPlaceholder}
|
||||||
|
placeholderTextColor='rgba(255,255,255,0.35)'
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
height: size(200),
|
height: 64,
|
||||||
fontSize: fontSize(12),
|
fontSize: 22,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
|
paddingHorizontal: 20,
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -11,8 +12,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
|
|
||||||
import { useTVFocusAnimation } from "@/components/tv";
|
|
||||||
import { verifyAccountPIN } from "@/utils/secureCredentials";
|
import { verifyAccountPIN } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
interface TVPINEntryModalProps {
|
interface TVPINEntryModalProps {
|
||||||
@@ -25,40 +24,122 @@ interface TVPINEntryModalProps {
|
|||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forgot PIN Button
|
// Number pad button
|
||||||
const TVForgotPINButton: React.FC<{
|
const NumberPadButton: React.FC<{
|
||||||
|
value: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
label: string;
|
|
||||||
hasTVPreferredFocus?: boolean;
|
hasTVPreferredFocus?: boolean;
|
||||||
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
|
isBackspace?: boolean;
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
disabled?: boolean;
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
}> = ({ value, onPress, hasTVPreferredFocus, isBackspace, disabled }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 100,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onFocus={handleFocus}
|
onFocus={() => {
|
||||||
onBlur={handleBlur}
|
setFocused(true);
|
||||||
|
animateTo(1.1);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
styles.numberButton,
|
||||||
{
|
{
|
||||||
paddingHorizontal: 16,
|
transform: [{ scale }],
|
||||||
paddingVertical: 10,
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: focused
|
|
||||||
? "rgba(168, 85, 247, 0.2)"
|
|
||||||
: "transparent",
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
>
|
||||||
|
{isBackspace ? (
|
||||||
|
<Ionicons
|
||||||
|
name='backspace-outline'
|
||||||
|
size={28}
|
||||||
|
color={focused ? "#000" : "#fff"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[styles.numberText, { color: focused ? "#000" : "#fff" }]}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// PIN dot indicator
|
||||||
|
const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({
|
||||||
|
filled,
|
||||||
|
error,
|
||||||
|
}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.pinDot,
|
||||||
|
filled && styles.pinDotFilled,
|
||||||
|
error && styles.pinDotError,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Forgot PIN link
|
||||||
|
const ForgotPINLink: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
}> = ({ onPress, label }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 100,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: focused ? "rgba(255,255,255,0.15)" : "transparent",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: focused ? "#d8b4fe" : "#a855f7",
|
color: focused ? "#fff" : "rgba(255,255,255,0.5)",
|
||||||
fontWeight: "500",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -80,23 +161,21 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [pinCode, setPinCode] = useState("");
|
const [pinCode, setPinCode] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState(false);
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
const pinInputRef = useRef<TVPinInputRef>(null);
|
|
||||||
|
|
||||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
const contentScale = useRef(new Animated.Value(0.9)).current;
|
||||||
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
// Reset state when opening
|
|
||||||
setPinCode("");
|
setPinCode("");
|
||||||
setError(null);
|
setError(false);
|
||||||
setIsVerifying(false);
|
setIsVerifying(false);
|
||||||
|
|
||||||
overlayOpacity.setValue(0);
|
overlayOpacity.setValue(0);
|
||||||
sheetTranslateY.setValue(200);
|
contentScale.setValue(0.9);
|
||||||
|
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(overlayOpacity, {
|
Animated.timing(overlayOpacity, {
|
||||||
@@ -105,32 +184,19 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
|||||||
easing: Easing.out(Easing.quad),
|
easing: Easing.out(Easing.quad),
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
Animated.timing(sheetTranslateY, {
|
Animated.timing(contentScale, {
|
||||||
toValue: 0,
|
toValue: 1,
|
||||||
duration: 300,
|
duration: 300,
|
||||||
easing: Easing.out(Easing.cubic),
|
easing: Easing.out(Easing.cubic),
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
}
|
|
||||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
const timer = setTimeout(() => setIsReady(true), 100);
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
}, [visible]);
|
}, [visible, overlayOpacity, contentScale]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible && isReady) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
pinInputRef.current?.focus();
|
|
||||||
}, 150);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [visible, isReady]);
|
|
||||||
|
|
||||||
const shake = () => {
|
const shake = () => {
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
@@ -157,33 +223,42 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
|||||||
]).start();
|
]).start();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePinChange = async (value: string) => {
|
const handleNumberPress = async (num: string) => {
|
||||||
setPinCode(value);
|
if (isVerifying || pinCode.length >= 4) return;
|
||||||
setError(null);
|
|
||||||
|
setError(false);
|
||||||
|
const newPin = pinCode + num;
|
||||||
|
setPinCode(newPin);
|
||||||
|
|
||||||
// Auto-verify when 4 digits entered
|
// Auto-verify when 4 digits entered
|
||||||
if (value.length === 4) {
|
if (newPin.length === 4) {
|
||||||
setIsVerifying(true);
|
setIsVerifying(true);
|
||||||
try {
|
try {
|
||||||
const isValid = await verifyAccountPIN(serverUrl, userId, value);
|
const isValid = await verifyAccountPIN(serverUrl, userId, newPin);
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
onSuccess();
|
onSuccess();
|
||||||
setPinCode("");
|
setPinCode("");
|
||||||
} else {
|
} else {
|
||||||
setError(t("pin.invalid_pin"));
|
setError(true);
|
||||||
shake();
|
shake();
|
||||||
setPinCode("");
|
setTimeout(() => setPinCode(""), 300);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("pin.invalid_pin"));
|
setError(true);
|
||||||
shake();
|
shake();
|
||||||
setPinCode("");
|
setTimeout(() => setPinCode(""), 300);
|
||||||
} finally {
|
} finally {
|
||||||
setIsVerifying(false);
|
setIsVerifying(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBackspace = () => {
|
||||||
|
if (isVerifying) return;
|
||||||
|
setError(false);
|
||||||
|
setPinCode((prev) => prev.slice(0, -1));
|
||||||
|
};
|
||||||
|
|
||||||
const handleForgotPIN = () => {
|
const handleForgotPIN = () => {
|
||||||
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
|
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
|
||||||
{ text: t("common.cancel"), style: "cancel" },
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
@@ -204,11 +279,11 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
|||||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.sheetContainer,
|
styles.contentContainer,
|
||||||
{ transform: [{ translateY: sheetTranslateY }] },
|
{ transform: [{ scale: contentScale }] },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
<BlurView intensity={60} tint='dark' style={styles.blurContainer}>
|
||||||
<TVFocusGuideView
|
<TVFocusGuideView
|
||||||
autoFocus
|
autoFocus
|
||||||
trapFocusUp
|
trapFocusUp
|
||||||
@@ -218,44 +293,103 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
|||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
|
||||||
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
|
<Text style={styles.subtitle}>{username}</Text>
|
||||||
<Text style={styles.subtitle}>
|
|
||||||
{t("pin.enter_pin_for", { username })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* PIN Input */}
|
{/* PIN Dots */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.pinDotsContainer,
|
||||||
|
{ transform: [{ translateX: shakeAnimation }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<PinDot key={i} filled={pinCode.length > i} error={error} />
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Number Pad */}
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<Animated.View
|
<View style={styles.numberPad}>
|
||||||
style={[
|
{/* Row 1: 1-3 */}
|
||||||
styles.pinContainer,
|
<View style={styles.numberRow}>
|
||||||
{ transform: [{ translateX: shakeAnimation }] },
|
<NumberPadButton
|
||||||
]}
|
value='1'
|
||||||
>
|
onPress={() => handleNumberPress("1")}
|
||||||
<TVPinInput
|
hasTVPreferredFocus
|
||||||
ref={pinInputRef}
|
disabled={isVerifying}
|
||||||
value={pinCode}
|
/>
|
||||||
onChangeText={handlePinChange}
|
<NumberPadButton
|
||||||
length={4}
|
value='2'
|
||||||
autoFocus
|
onPress={() => handleNumberPress("2")}
|
||||||
/>
|
disabled={isVerifying}
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
/>
|
||||||
{isVerifying && (
|
<NumberPadButton
|
||||||
<Text style={styles.verifyingText}>
|
value='3'
|
||||||
{t("common.verifying")}
|
onPress={() => handleNumberPress("3")}
|
||||||
</Text>
|
disabled={isVerifying}
|
||||||
)}
|
/>
|
||||||
</Animated.View>
|
</View>
|
||||||
|
{/* Row 2: 4-6 */}
|
||||||
|
<View style={styles.numberRow}>
|
||||||
|
<NumberPadButton
|
||||||
|
value='4'
|
||||||
|
onPress={() => handleNumberPress("4")}
|
||||||
|
disabled={isVerifying}
|
||||||
|
/>
|
||||||
|
<NumberPadButton
|
||||||
|
value='5'
|
||||||
|
onPress={() => handleNumberPress("5")}
|
||||||
|
disabled={isVerifying}
|
||||||
|
/>
|
||||||
|
<NumberPadButton
|
||||||
|
value='6'
|
||||||
|
onPress={() => handleNumberPress("6")}
|
||||||
|
disabled={isVerifying}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{/* Row 3: 7-9 */}
|
||||||
|
<View style={styles.numberRow}>
|
||||||
|
<NumberPadButton
|
||||||
|
value='7'
|
||||||
|
onPress={() => handleNumberPress("7")}
|
||||||
|
disabled={isVerifying}
|
||||||
|
/>
|
||||||
|
<NumberPadButton
|
||||||
|
value='8'
|
||||||
|
onPress={() => handleNumberPress("8")}
|
||||||
|
disabled={isVerifying}
|
||||||
|
/>
|
||||||
|
<NumberPadButton
|
||||||
|
value='9'
|
||||||
|
onPress={() => handleNumberPress("9")}
|
||||||
|
disabled={isVerifying}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{/* Row 4: empty, 0, backspace */}
|
||||||
|
<View style={styles.numberRow}>
|
||||||
|
<View style={styles.numberButtonPlaceholder} />
|
||||||
|
<NumberPadButton
|
||||||
|
value='0'
|
||||||
|
onPress={() => handleNumberPress("0")}
|
||||||
|
disabled={isVerifying}
|
||||||
|
/>
|
||||||
|
<NumberPadButton
|
||||||
|
value=''
|
||||||
|
onPress={handleBackspace}
|
||||||
|
isBackspace
|
||||||
|
disabled={isVerifying || pinCode.length === 0}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Forgot PIN */}
|
{/* Forgot PIN */}
|
||||||
{isReady && onForgotPIN && (
|
{isReady && onForgotPIN && (
|
||||||
<View style={styles.forgotContainer}>
|
<View style={styles.forgotContainer}>
|
||||||
<TVForgotPINButton
|
<ForgotPINLink
|
||||||
onPress={handleForgotPIN}
|
onPress={handleForgotPIN}
|
||||||
label={t("pin.forgot_pin")}
|
label={t("pin.forgot_pin")}
|
||||||
hasTVPreferredFocus
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -273,55 +407,81 @@ const styles = StyleSheet.create({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
},
|
},
|
||||||
sheetContainer: {
|
contentContainer: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
},
|
},
|
||||||
blurContainer: {
|
blurContainer: {
|
||||||
borderTopLeftRadius: 24,
|
borderRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
paddingTop: 24,
|
padding: 40,
|
||||||
paddingBottom: 50,
|
alignItems: "center",
|
||||||
overflow: "visible",
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
paddingHorizontal: 48,
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
marginBottom: 4,
|
marginBottom: 8,
|
||||||
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 32,
|
||||||
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
pinContainer: {
|
pinDotsContainer: {
|
||||||
paddingHorizontal: 48,
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
pinDot: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "rgba(255,255,255,0.4)",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
pinDotFilled: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderColor: "#fff",
|
||||||
|
},
|
||||||
|
pinDotError: {
|
||||||
|
borderColor: "#ef4444",
|
||||||
|
backgroundColor: "#ef4444",
|
||||||
|
},
|
||||||
|
numberPad: {
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
numberRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
numberButton: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 36,
|
||||||
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 16,
|
|
||||||
},
|
},
|
||||||
errorText: {
|
numberButtonPlaceholder: {
|
||||||
color: "#ef4444",
|
width: 72,
|
||||||
fontSize: 14,
|
height: 72,
|
||||||
marginTop: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
},
|
||||||
verifyingText: {
|
numberText: {
|
||||||
color: "rgba(255,255,255,0.6)",
|
fontSize: 28,
|
||||||
fontSize: 14,
|
fontWeight: "600",
|
||||||
marginTop: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
},
|
||||||
forgotContainer: {
|
forgotContainer: {
|
||||||
alignItems: "center",
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ const TVSubmitButton: React.FC<{
|
|||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
backgroundColor: focused
|
backgroundColor: focused
|
||||||
? "#a855f7"
|
? "#fff"
|
||||||
: isDisabled
|
: isDisabled
|
||||||
? "#4a4a4a"
|
? "#4a4a4a"
|
||||||
: "#7c3aed",
|
: "rgba(255,255,255,0.15)",
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
@@ -64,14 +64,18 @@ const TVSubmitButton: React.FC<{
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator size='small' color='#fff' />
|
<ActivityIndicator size='small' color={focused ? "#000" : "#fff"} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name='log-in-outline' size={20} color='#fff' />
|
<Ionicons
|
||||||
|
name='log-in-outline'
|
||||||
|
size={20}
|
||||||
|
color={focused ? "#000" : "#fff"}
|
||||||
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -119,7 +123,7 @@ const TVPasswordInput: React.FC<{
|
|||||||
backgroundColor: "#1F2937",
|
backgroundColor: "#1F2937",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: focused ? "#6366F1" : "#374151",
|
borderColor: focused ? "#fff" : "#374151",
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
},
|
},
|
||||||
@@ -245,14 +249,16 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
|||||||
{/* Password Input */}
|
{/* Password Input */}
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
<Text style={styles.inputLabel}>{t("login.password")}</Text>
|
<Text style={styles.inputLabel}>
|
||||||
|
{t("login.password_placeholder")}
|
||||||
|
</Text>
|
||||||
<TVPasswordInput
|
<TVPasswordInput
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => {
|
||||||
setPassword(text);
|
setPassword(text);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder={t("login.password")}
|
placeholder={t("login.password_placeholder")}
|
||||||
onSubmitEditing={handleSubmit}
|
onSubmitEditing={handleSubmit}
|
||||||
hasTVPreferredFocus
|
hasTVPreferredFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,512 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BlurView } from "expo-blur";
|
|
||||||
import type React from "react";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Animated,
|
|
||||||
Easing,
|
|
||||||
Modal,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useMMKVString } from "react-native-mmkv";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import {
|
|
||||||
deleteAccountCredential,
|
|
||||||
getPreviousServers,
|
|
||||||
type SavedServer,
|
|
||||||
type SavedServerAccount,
|
|
||||||
} from "@/utils/secureCredentials";
|
|
||||||
import { TVAccountCard } from "./TVAccountCard";
|
|
||||||
import { TVServerCard } from "./TVServerCard";
|
|
||||||
|
|
||||||
// Action card for server action sheet (Apple TV style)
|
|
||||||
const TVServerActionCard: React.FC<{
|
|
||||||
label: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
variant?: "default" | "destructive";
|
|
||||||
hasTVPreferredFocus?: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
|
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
|
||||||
|
|
||||||
const animateTo = (v: number) =>
|
|
||||||
Animated.timing(scale, {
|
|
||||||
toValue: v,
|
|
||||||
duration: 150,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start();
|
|
||||||
|
|
||||||
const isDestructive = variant === "destructive";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={onPress}
|
|
||||||
onFocus={() => {
|
|
||||||
setFocused(true);
|
|
||||||
animateTo(1.05);
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
setFocused(false);
|
|
||||||
animateTo(1);
|
|
||||||
}}
|
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
transform: [{ scale }],
|
|
||||||
width: 180,
|
|
||||||
height: 90,
|
|
||||||
backgroundColor: focused
|
|
||||||
? isDestructive
|
|
||||||
? "#ef4444"
|
|
||||||
: "#fff"
|
|
||||||
: isDestructive
|
|
||||||
? "rgba(239, 68, 68, 0.2)"
|
|
||||||
: "rgba(255,255,255,0.08)",
|
|
||||||
borderRadius: 14,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={icon}
|
|
||||||
size={28}
|
|
||||||
color={
|
|
||||||
focused
|
|
||||||
? isDestructive
|
|
||||||
? "#fff"
|
|
||||||
: "#000"
|
|
||||||
: isDestructive
|
|
||||||
? "#ef4444"
|
|
||||||
: "#fff"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 16,
|
|
||||||
color: focused
|
|
||||||
? isDestructive
|
|
||||||
? "#fff"
|
|
||||||
: "#000"
|
|
||||||
: isDestructive
|
|
||||||
? "#ef4444"
|
|
||||||
: "#fff",
|
|
||||||
fontWeight: "600",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Animated.View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Server action sheet component (bottom sheet with horizontal scrolling)
|
|
||||||
const TVServerActionSheet: React.FC<{
|
|
||||||
visible: boolean;
|
|
||||||
server: SavedServer | null;
|
|
||||||
onLogin: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}> = ({ visible, server, onLogin, onDelete, onClose }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!server) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent
|
|
||||||
animationType='fade'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BlurView
|
|
||||||
intensity={80}
|
|
||||||
tint='dark'
|
|
||||||
style={{
|
|
||||||
borderTopLeftRadius: 24,
|
|
||||||
borderTopRightRadius: 24,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingTop: 24,
|
|
||||||
paddingBottom: 50,
|
|
||||||
overflow: "visible",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Title */}
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "500",
|
|
||||||
color: "rgba(255,255,255,0.6)",
|
|
||||||
marginBottom: 8,
|
|
||||||
paddingHorizontal: 48,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{server.name || server.address}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Horizontal options */}
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
style={{ overflow: "visible" }}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: 48,
|
|
||||||
paddingVertical: 10,
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TVServerActionCard
|
|
||||||
label={t("common.login")}
|
|
||||||
icon='log-in-outline'
|
|
||||||
hasTVPreferredFocus
|
|
||||||
onPress={onLogin}
|
|
||||||
/>
|
|
||||||
<TVServerActionCard
|
|
||||||
label={t("common.delete")}
|
|
||||||
icon='trash-outline'
|
|
||||||
variant='destructive'
|
|
||||||
onPress={onDelete}
|
|
||||||
/>
|
|
||||||
<TVServerActionCard
|
|
||||||
label={t("common.cancel")}
|
|
||||||
icon='close-outline'
|
|
||||||
onPress={onClose}
|
|
||||||
/>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</BlurView>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TVPreviousServersListProps {
|
|
||||||
onServerSelect: (server: SavedServer) => void;
|
|
||||||
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
|
|
||||||
onPasswordLogin?: (
|
|
||||||
serverUrl: string,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
) => Promise<void>;
|
|
||||||
onAddAccount?: (server: SavedServer) => void;
|
|
||||||
onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void;
|
|
||||||
onPasswordRequired?: (
|
|
||||||
server: SavedServer,
|
|
||||||
account: SavedServerAccount,
|
|
||||||
) => void;
|
|
||||||
// Called when server is pressed to show action sheet (handled by parent)
|
|
||||||
onServerAction?: (server: SavedServer) => void;
|
|
||||||
// Called by parent when "Login" is selected from action sheet
|
|
||||||
loginServerOverride?: SavedServer | null;
|
|
||||||
// Disable all focusable elements (when a modal is open)
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export the action sheet for use in parent components
|
|
||||||
export { TVServerActionSheet };
|
|
||||||
|
|
||||||
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|
||||||
onServerSelect,
|
|
||||||
onQuickLogin,
|
|
||||||
onAddAccount,
|
|
||||||
onPinRequired,
|
|
||||||
onPasswordRequired,
|
|
||||||
onServerAction,
|
|
||||||
loginServerOverride,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [_previousServers, setPreviousServers] =
|
|
||||||
useMMKVString("previousServers");
|
|
||||||
const [loadingServer, setLoadingServer] = useState<string | null>(null);
|
|
||||||
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [showAccountsModal, setShowAccountsModal] = useState(false);
|
|
||||||
|
|
||||||
const previousServers = useMemo(() => {
|
|
||||||
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
|
||||||
}, [_previousServers]);
|
|
||||||
|
|
||||||
// When parent triggers login via loginServerOverride, execute the login flow
|
|
||||||
useEffect(() => {
|
|
||||||
if (loginServerOverride) {
|
|
||||||
const accountCount = loginServerOverride.accounts?.length || 0;
|
|
||||||
|
|
||||||
if (accountCount === 0) {
|
|
||||||
onServerSelect(loginServerOverride);
|
|
||||||
} else if (accountCount === 1) {
|
|
||||||
handleAccountLogin(
|
|
||||||
loginServerOverride,
|
|
||||||
loginServerOverride.accounts[0],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setSelectedServer(loginServerOverride);
|
|
||||||
setShowAccountsModal(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [loginServerOverride]);
|
|
||||||
|
|
||||||
const refreshServers = () => {
|
|
||||||
const servers = getPreviousServers();
|
|
||||||
setPreviousServers(JSON.stringify(servers));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAccountLogin = async (
|
|
||||||
server: SavedServer,
|
|
||||||
account: SavedServerAccount,
|
|
||||||
) => {
|
|
||||||
setShowAccountsModal(false);
|
|
||||||
|
|
||||||
switch (account.securityType) {
|
|
||||||
case "none":
|
|
||||||
if (onQuickLogin) {
|
|
||||||
setLoadingServer(server.address);
|
|
||||||
try {
|
|
||||||
await onQuickLogin(server.address, account.userId);
|
|
||||||
} catch {
|
|
||||||
Alert.alert(
|
|
||||||
t("server.session_expired"),
|
|
||||||
t("server.please_login_again"),
|
|
||||||
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoadingServer(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "pin":
|
|
||||||
if (onPinRequired) {
|
|
||||||
onPinRequired(server, account);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "password":
|
|
||||||
if (onPasswordRequired) {
|
|
||||||
onPasswordRequired(server, account);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleServerPress = (server: SavedServer) => {
|
|
||||||
if (loadingServer) return;
|
|
||||||
|
|
||||||
// If onServerAction is provided, delegate to parent for action sheet handling
|
|
||||||
if (onServerAction) {
|
|
||||||
onServerAction(server);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: direct login flow (for backwards compatibility)
|
|
||||||
const accountCount = server.accounts?.length || 0;
|
|
||||||
if (accountCount === 0) {
|
|
||||||
onServerSelect(server);
|
|
||||||
} else if (accountCount === 1) {
|
|
||||||
handleAccountLogin(server, server.accounts[0]);
|
|
||||||
} else {
|
|
||||||
setSelectedServer(server);
|
|
||||||
setShowAccountsModal(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getServerSubtitle = (server: SavedServer): string | undefined => {
|
|
||||||
const accountCount = server.accounts?.length || 0;
|
|
||||||
|
|
||||||
if (accountCount > 1) {
|
|
||||||
return t("server.accounts_count", { count: accountCount });
|
|
||||||
}
|
|
||||||
if (accountCount === 1) {
|
|
||||||
return `${server.accounts[0].username} • ${t("server.saved")}`;
|
|
||||||
}
|
|
||||||
return server.name ? server.address : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSecurityIcon = (
|
|
||||||
server: SavedServer,
|
|
||||||
): keyof typeof Ionicons.glyphMap | null => {
|
|
||||||
const accountCount = server.accounts?.length || 0;
|
|
||||||
if (accountCount === 0) return null;
|
|
||||||
|
|
||||||
if (accountCount > 1) {
|
|
||||||
return "people";
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = server.accounts[0];
|
|
||||||
switch (account.securityType) {
|
|
||||||
case "pin":
|
|
||||||
return "keypad";
|
|
||||||
case "password":
|
|
||||||
return "lock-closed";
|
|
||||||
default:
|
|
||||||
return "key";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAccount = async (account: SavedServerAccount) => {
|
|
||||||
if (!selectedServer) return;
|
|
||||||
|
|
||||||
Alert.alert(
|
|
||||||
t("server.remove_saved_login"),
|
|
||||||
t("server.remove_account_description", { username: account.username }),
|
|
||||||
[
|
|
||||||
{ text: t("common.cancel"), style: "cancel" },
|
|
||||||
{
|
|
||||||
text: t("common.remove"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
await deleteAccountCredential(
|
|
||||||
selectedServer.address,
|
|
||||||
account.userId,
|
|
||||||
);
|
|
||||||
refreshServers();
|
|
||||||
if (selectedServer.accounts.length <= 1) {
|
|
||||||
setShowAccountsModal(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!previousServers.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ marginTop: 32 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#FFFFFF",
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("server.previous_servers")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={{ gap: 12 }}>
|
|
||||||
{previousServers.map((server) => (
|
|
||||||
<TVServerCard
|
|
||||||
key={server.address}
|
|
||||||
title={server.name || server.address}
|
|
||||||
subtitle={getServerSubtitle(server)}
|
|
||||||
securityIcon={getSecurityIcon(server)}
|
|
||||||
isLoading={loadingServer === server.address}
|
|
||||||
onPress={() => handleServerPress(server)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* TV Account Selection Modal */}
|
|
||||||
<Modal
|
|
||||||
visible={showAccountsModal}
|
|
||||||
transparent
|
|
||||||
animationType='fade'
|
|
||||||
onRequestClose={() => setShowAccountsModal(false)}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 80,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: 40,
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "#FFFFFF",
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("server.select_account")}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 18,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginBottom: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedServer?.name || selectedServer?.address}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={{ gap: 12, marginBottom: 24 }}>
|
|
||||||
{selectedServer?.accounts.map((account, index) => (
|
|
||||||
<TVAccountCard
|
|
||||||
key={account.userId}
|
|
||||||
account={account}
|
|
||||||
onPress={() =>
|
|
||||||
selectedServer &&
|
|
||||||
handleAccountLogin(selectedServer, account)
|
|
||||||
}
|
|
||||||
onLongPress={() => handleDeleteAccount(account)}
|
|
||||||
hasTVPreferredFocus={index === 0}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ gap: 12 }}>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
setShowAccountsModal(false);
|
|
||||||
if (selectedServer && onAddAccount) {
|
|
||||||
onAddAccount(selectedServer);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color='purple'
|
|
||||||
>
|
|
||||||
{t("server.add_account")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onPress={() => setShowAccountsModal(false)}
|
|
||||||
color='black'
|
|
||||||
className='bg-neutral-800'
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -75,10 +75,10 @@ const TVSaveButton: React.FC<{
|
|||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
backgroundColor: focused
|
backgroundColor: focused
|
||||||
? "#a855f7"
|
? "#fff"
|
||||||
: disabled
|
: disabled
|
||||||
? "#4a4a4a"
|
? "#4a4a4a"
|
||||||
: "#7c3aed",
|
: "rgba(255,255,255,0.15)",
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
@@ -89,11 +89,15 @@ const TVSaveButton: React.FC<{
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name='checkmark' size={20} color='#fff' />
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={20}
|
||||||
|
color={focused ? "#000" : "#fff"}
|
||||||
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user