mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
Compare commits
228 Commits
feat/tv-in
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c400a54a | ||
|
|
ea5a999f21 | ||
|
|
dffcdef945 | ||
|
|
fa1c3f3947 | ||
|
|
2761de5a74 | ||
|
|
261f7cc0cd | ||
|
|
d06daef933 | ||
|
|
feca1d7e9c | ||
|
|
6b6bfd1a89 | ||
|
|
d585b20f49 | ||
|
|
692ccfdb2c | ||
|
|
86e39c444c | ||
|
|
ed7928b4d3 | ||
|
|
27dc7b5664 | ||
|
|
a205c75895 | ||
|
|
a4d4f588fb | ||
|
|
aedb7bc51d | ||
|
|
5a3e9c51c9 | ||
|
|
252c58f120 | ||
|
|
d2e73021b1 | ||
|
|
6876ce046f | ||
|
|
e044859aaf | ||
|
|
c93132177c | ||
|
|
2166bb3867 | ||
|
|
f9b71ef648 | ||
|
|
d11fb3d0c0 | ||
|
|
dd3ca37108 | ||
|
|
b588195456 | ||
|
|
fe4d90df26 | ||
|
|
a190122eea | ||
|
|
0bf8fac079 | ||
|
|
37b51abd34 | ||
|
|
6fe464088b | ||
|
|
769c7a2432 | ||
|
|
62c86533b1 | ||
|
|
4fc78f006d | ||
|
|
ab0957044f | ||
|
|
407ef3f51e | ||
|
|
0e531da2e0 | ||
|
|
1cabbf087e | ||
|
|
0f86c776ba | ||
|
|
07b79de203 | ||
|
|
e4b0161d15 | ||
|
|
6e223596f6 | ||
|
|
04e75c81a4 | ||
|
|
b6ea6d4f14 | ||
|
|
2c58636843 | ||
|
|
d9266209d2 | ||
|
|
4cc11403f8 | ||
|
|
0ba3f44615 | ||
|
|
a3ed822bf4 | ||
|
|
f8414194f0 | ||
|
|
1e9c9fb67f | ||
|
|
3c7292b73b | ||
|
|
708d0e8d2e | ||
|
|
74f5844ed7 | ||
|
|
38d638cdeb | ||
|
|
afe9d33ee4 | ||
|
|
5db4a79e8a | ||
|
|
c12f252079 | ||
|
|
70a0033094 | ||
|
|
3379cedc01 | ||
|
|
63adb98540 | ||
|
|
4f721c3ab6 | ||
|
|
cf91c4c682 | ||
|
|
eb02ac253a | ||
|
|
1545790528 | ||
|
|
82eaf62354 | ||
|
|
05d9b8f32c | ||
|
|
df16ea9de9 | ||
|
|
2486b2c069 | ||
|
|
4a4d6948cf | ||
|
|
e10255c328 | ||
|
|
08fc02a6b1 | ||
|
|
bba396c08c | ||
|
|
3316508217 | ||
|
|
5fd136ef0b | ||
|
|
2c8160188f | ||
|
|
d56d202bfc | ||
|
|
f1c2b9de53 | ||
|
|
11ec778bd8 | ||
|
|
796e12b4ac | ||
|
|
0c6ef5cbda | ||
|
|
fcd19d9c6d | ||
|
|
d72cec0f4f | ||
|
|
1e14c7ec46 | ||
|
|
39a168456a | ||
|
|
c8ddb9a892 | ||
|
|
9ee71a002d | ||
|
|
5ede3f30d0 | ||
|
|
6b0f8b833f | ||
|
|
4253f0d5ab | ||
|
|
c950408bdb | ||
|
|
4b577b8111 | ||
|
|
52ad1a06e1 | ||
|
|
f1140927a8 | ||
|
|
03f17a758f | ||
|
|
5fd8e40c44 | ||
|
|
b076f9f3d9 | ||
|
|
1ac0644a57 | ||
|
|
d272c6710c | ||
|
|
09bd84593c | ||
|
|
f8a84e34fd | ||
|
|
0aa2dc5924 | ||
|
|
e7f200a114 | ||
|
|
da9afacbf7 | ||
|
|
cc89b5df89 | ||
|
|
d1794798e7 | ||
|
|
11a4f14732 | ||
|
|
121ff0eea0 | ||
|
|
80fdd579f3 | ||
|
|
f79cf1925d | ||
|
|
8bb0d845a2 | ||
|
|
c7cd8217c9 | ||
|
|
235ba1473f | ||
|
|
284a4e3d41 | ||
|
|
1fd3574520 | ||
|
|
f1188c090a | ||
|
|
1321a5c000 | ||
|
|
3d18312903 | ||
|
|
4bef386b82 | ||
|
|
e84cea6427 | ||
|
|
52bc5e912d | ||
|
|
023bd15ca2 | ||
|
|
7bccafc476 | ||
|
|
8df61838d4 | ||
|
|
55776d887f | ||
|
|
2e7079cb5a | ||
|
|
a1c98f9285 | ||
|
|
ca4f24ded0 | ||
|
|
92deba14f3 | ||
|
|
ece5750d34 | ||
|
|
428455f6a6 | ||
|
|
8c749cdc4d | ||
|
|
7ed0c00ce7 | ||
|
|
222ae69644 | ||
|
|
fec8df37f7 | ||
|
|
0e0e722e1c | ||
|
|
2ce810c191 | ||
|
|
564a593a3a | ||
|
|
479e23f001 | ||
|
|
019f863b3e | ||
|
|
54d8693999 | ||
|
|
8c0cbffd98 | ||
|
|
9f9d949891 | ||
|
|
3438e78cab | ||
|
|
67bca1f989 | ||
|
|
c35e97f388 | ||
|
|
bc575c26c1 | ||
|
|
ab526f2c6b | ||
|
|
7d0b3be8c2 | ||
|
|
a384b34402 | ||
|
|
07f535a6e4 | ||
|
|
2bcf52209e | ||
|
|
fb7cee7718 | ||
|
|
2775075187 | ||
|
|
4962f2161f | ||
|
|
25ec9c4348 | ||
|
|
d17414bc93 | ||
|
|
fea3e1449a | ||
|
|
ad1d9b5888 | ||
|
|
3d406314a4 | ||
|
|
e6598f0944 | ||
|
|
f549e8eaed | ||
|
|
dab1c10a03 | ||
|
|
7e2962e539 | ||
|
|
81cf672eb7 | ||
|
|
591d89c19f | ||
|
|
44b7434cdd | ||
|
|
8a782fb0c9 | ||
|
|
7d0b6c37b8 | ||
|
|
5443222e4e | ||
|
|
a77e86be21 | ||
|
|
9d82549cd6 | ||
|
|
cb5cb8bff7 | ||
|
|
0b61fb7d98 | ||
|
|
f762b9017f | ||
|
|
b063fd6405 | ||
|
|
88ac94ebeb | ||
|
|
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 | ||
|
|
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
|
||||||
|
|
||||||
@@ -41,3 +44,5 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
|
|||||||
- **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)_
|
- **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)_
|
- **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`.
|
||||||
93
.github/pull_request_template.md
vendored
93
.github/pull_request_template.md
vendored
@@ -1,91 +1,54 @@
|
|||||||
<!--
|
<!--
|
||||||
Pull Request Template for Streamyfin
|
Use a conventional commit title for the PR title,
|
||||||
====================================
|
for example `feat(auth): add MFA`
|
||||||
Use this template to help reviewers understand the purpose of your PR
|
All sections below are required. Write N/A if a section is not applicable.
|
||||||
and to ensure all necessary checks are completed before merging.
|
If you use AI to help implement this PR, you must declare it below. It's very important that the feature or fix implemented has been tested thoroughly by you personally on all target platforms. Only adding AI generated code without proper testing is not allowed and this PR will be closed immediately.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# 📦 Pull Request
|
# 📦 Pull Request
|
||||||
|
|
||||||
## 🔖 Summary
|
|
||||||
<!--
|
<!--
|
||||||
A concise description of the changes introduced by this PR.
|
🤖 AI ASSISTED?
|
||||||
Example:
|
Uncomment the line below if AI was used to assist with this PR:
|
||||||
“Add real-time currency conversion widget to dashboard.”
|
-->
|
||||||
|
<!--
|
||||||
|
[](#) -->
|
||||||
|
|
||||||
|
## 📝 Description
|
||||||
|
<!--
|
||||||
|
A short description of the changes and why you're making them.
|
||||||
|
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## 🏷️ Ticket / Issue
|
## 🏷️ Ticket / Issue
|
||||||
<!--
|
<!--
|
||||||
Link to the related ticket, issue or user story.
|
Link to the related ticket, issue or user story.
|
||||||
You can also indicate if this PR supersedes a previous one.
|
Example: Fixes #123
|
||||||
Example:
|
|
||||||
- Closes #123
|
|
||||||
- Fixes STREAMYFIN-456
|
|
||||||
- Resolves #789
|
|
||||||
- Supersedes #120
|
|
||||||
- Related: #130
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## 🛠️ What’s Changed
|
|
||||||
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
|
|
||||||
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
|
|
||||||
Spec: https://www.conventionalcommits.org/ -->
|
|
||||||
|
|
||||||
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
|
|
||||||
- Scope (optional): e.g., auth, billing, mobile
|
|
||||||
- Short summary: what changed and why (1–2 lines)
|
|
||||||
-->
|
|
||||||
|
|
||||||
## 📋 Details
|
|
||||||
<!--
|
|
||||||
Provide more context or background. Explain any non-obvious decisions.
|
|
||||||
Include screenshots or GIFs for UI changes if applicable.
|
|
||||||
-->
|
|
||||||
|
|
||||||
### ⚠️ Breaking Changes
|
|
||||||
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
|
|
||||||
|
|
||||||
### 🔐 Security & Privacy Impact
|
|
||||||
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
|
|
||||||
|
|
||||||
### ⚡ Performance Impact
|
|
||||||
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
|
|
||||||
|
|
||||||
### 🖼️ Screenshots / GIFs (if UI)
|
### 🖼️ Screenshots / GIFs (if UI)
|
||||||
<!-- Before/After, dark mode, responsive states. -->
|
<!--
|
||||||
|
Include screenshots of relevant UI changes for both Android and iOS.
|
||||||
|
Before/After, responsive states (if relevant).
|
||||||
|
-->
|
||||||
|
|
||||||
## ✅ Checklist
|
## ✅ Checklist
|
||||||
<!--
|
<!--
|
||||||
Review and check off items as you complete them.
|
Review and check off items as you complete them.
|
||||||
-->
|
-->
|
||||||
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
||||||
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
|
- [ ] Verified that changes behave as expected for all platforms
|
||||||
- [ ] Type checks pass (tsc/biome/etc.)
|
- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
||||||
- [ ] Docs updated (README/ADR/usage/API)
|
- [ ] No secrets, hardcoded credentials, or private config files are included
|
||||||
- [ ] No secrets/credentials included; env vars documented
|
- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
||||||
- [ ] Release notes/CHANGELOG entry added (if applicable)
|
|
||||||
- [ ] Verified locally that changes behave as expected
|
|
||||||
|
|
||||||
## 🔍 Testing Instructions
|
## 🔍 Testing Instructions
|
||||||
<!--
|
<!--
|
||||||
Describe how reviewers can test your changes.
|
Describe how reviewers can test your changes. This will help the PR get merged faster.
|
||||||
Example:
|
Example:
|
||||||
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
|
1. Open the settings page and scroll to the bottom
|
||||||
2. Install deps: `npm|pnpm|yarn|bun install`
|
2. Verify that the clear data button is visible and pressable
|
||||||
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
|
3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
|
||||||
4. Run tests: `npm|pnpm|yarn|bun test`
|
4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
|
||||||
5. Verification steps:
|
|
||||||
- [ ] Expected UI/endpoint behavior
|
|
||||||
- [ ] Logs show no errors
|
|
||||||
- [ ] Edge cases covered (list)
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## ⚙️ Deployment Notes
|
|
||||||
<!--
|
|
||||||
Describe any deployment considerations such as config, environment vars, or native builds.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## 📝 Additional Notes
|
|
||||||
<!--
|
|
||||||
Any other information or references related to this PR.
|
|
||||||
-->
|
|
||||||
19
.github/renovate.json
vendored
19
.github/renovate.json
vendored
@@ -25,6 +25,25 @@
|
|||||||
"osvVulnerabilityAlerts": true,
|
"osvVulnerabilityAlerts": true,
|
||||||
"configMigration": true,
|
"configMigration": true,
|
||||||
"separateMinorPatch": true,
|
"separateMinorPatch": true,
|
||||||
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"managerFilePatterns": ["/\\.ya?ml$/"],
|
||||||
|
"matchStrings": [
|
||||||
|
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
||||||
|
],
|
||||||
|
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"customDatasources": {
|
||||||
|
"xcode": {
|
||||||
|
"defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json",
|
||||||
|
"format": "json",
|
||||||
|
"transformTemplates": [
|
||||||
|
"{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"lockFileMaintenance": {
|
"lockFileMaintenance": {
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
75
.github/workflows/artifact-comment.yml
vendored
75
.github/workflows/artifact-comment.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🔍 Get PR and Artifacts
|
- name: 🔍 Get PR and Artifacts
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
// Check if we're running from a fork (more precise detection)
|
// Check if we're running from a fork (more precise detection)
|
||||||
@@ -188,6 +188,17 @@ jobs:
|
|||||||
if (latestAppsRun) {
|
if (latestAppsRun) {
|
||||||
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
||||||
|
|
||||||
|
// Map job names to our build targets. Declared outside the try so
|
||||||
|
// the catch fallback can reuse the same keys.
|
||||||
|
const jobMappings = {
|
||||||
|
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
||||||
|
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
||||||
|
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
|
||||||
|
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
|
||||||
|
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
|
||||||
|
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all jobs for this workflow run
|
// Get all jobs for this workflow run
|
||||||
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||||
@@ -216,13 +227,6 @@ jobs:
|
|||||||
return; // Exit early
|
return; // Exit early
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map job names to our build targets
|
|
||||||
const jobMappings = {
|
|
||||||
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
|
||||||
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
|
||||||
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create individual status for each job
|
// Create individual status for each job
|
||||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||||
const job = jobs.jobs.find(j =>
|
const job = jobs.jobs.find(j =>
|
||||||
@@ -236,7 +240,9 @@ jobs:
|
|||||||
conclusion: job.conclusion,
|
conclusion: job.conclusion,
|
||||||
url: job.html_url,
|
url: job.html_url,
|
||||||
runId: latestAppsRun.id,
|
runId: latestAppsRun.id,
|
||||||
created_at: job.started_at || latestAppsRun.created_at
|
created_at: job.started_at || latestAppsRun.created_at,
|
||||||
|
started_at: job.started_at,
|
||||||
|
completed_at: job.completed_at
|
||||||
};
|
};
|
||||||
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
|
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
|
||||||
} else {
|
} else {
|
||||||
@@ -247,22 +253,30 @@ jobs:
|
|||||||
conclusion: latestAppsRun.conclusion,
|
conclusion: latestAppsRun.conclusion,
|
||||||
url: latestAppsRun.html_url,
|
url: latestAppsRun.html_url,
|
||||||
runId: latestAppsRun.id,
|
runId: latestAppsRun.id,
|
||||||
created_at: latestAppsRun.created_at
|
created_at: latestAppsRun.created_at,
|
||||||
|
started_at: latestAppsRun.run_started_at,
|
||||||
|
completed_at: latestAppsRun.updated_at
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
|
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
|
||||||
// Fallback to workflow-level status
|
// Fallback to workflow-level status for every build target.
|
||||||
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
|
// Keys must match jobMappings / buildTargets statusKey values.
|
||||||
|
const fallbackStatus = {
|
||||||
name: latestAppsRun.name,
|
name: latestAppsRun.name,
|
||||||
status: latestAppsRun.status,
|
status: latestAppsRun.status,
|
||||||
conclusion: latestAppsRun.conclusion,
|
conclusion: latestAppsRun.conclusion,
|
||||||
url: latestAppsRun.html_url,
|
url: latestAppsRun.html_url,
|
||||||
runId: latestAppsRun.id,
|
runId: latestAppsRun.id,
|
||||||
created_at: latestAppsRun.created_at
|
created_at: latestAppsRun.created_at,
|
||||||
|
started_at: latestAppsRun.run_started_at,
|
||||||
|
completed_at: latestAppsRun.updated_at
|
||||||
};
|
};
|
||||||
|
for (const platform of Object.keys(jobMappings)) {
|
||||||
|
buildStatuses[platform] = fallbackStatus;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect artifacts if any job has completed successfully
|
// Collect artifacts if any job has completed successfully
|
||||||
@@ -353,10 +367,12 @@ jobs:
|
|||||||
|
|
||||||
// Process each expected build target individually
|
// Process each expected build target individually
|
||||||
const buildTargets = [
|
const buildTargets = [
|
||||||
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||||
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||||
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
|
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
|
||||||
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
|
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
|
||||||
|
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
|
||||||
|
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const target of buildTargets) {
|
for (const target of buildTargets) {
|
||||||
@@ -371,16 +387,31 @@ jobs:
|
|||||||
let status = '⏳ Pending';
|
let status = '⏳ Pending';
|
||||||
let downloadLink = '*Waiting for build...*';
|
let downloadLink = '*Waiting for build...*';
|
||||||
|
|
||||||
// Special case for iOS TV - show as disabled
|
// tvOS builds are temporarily disabled until feat/tv-interface
|
||||||
if (target.name === 'iOS TV') {
|
// is merged - show them as disabled instead of stuck pending.
|
||||||
|
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
|
||||||
status = '💤 Disabled';
|
status = '💤 Disabled';
|
||||||
downloadLink = '*Disabled for now*';
|
downloadLink = '*Disabled until feat/tv-interface is merged*';
|
||||||
} else if (matchingStatus) {
|
} else if (matchingStatus) {
|
||||||
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
||||||
status = '✅ Complete';
|
status = '✅ Complete';
|
||||||
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
|
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
|
||||||
const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
|
const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
|
||||||
downloadLink = `[📥 Download ${fileType}](${directLink})`;
|
|
||||||
|
// Format file size
|
||||||
|
const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1);
|
||||||
|
const sizeInfo = `(${sizeInMB} MB)`;
|
||||||
|
|
||||||
|
// Calculate build duration
|
||||||
|
let durationInfo = '';
|
||||||
|
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
||||||
|
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
||||||
|
const durationMin = Math.floor(durationMs / 60000);
|
||||||
|
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||||
|
durationInfo = ` - ${durationMin}m ${durationSec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
||||||
} else if (matchingStatus.conclusion === 'failure') {
|
} else if (matchingStatus.conclusion === 'failure') {
|
||||||
status = `❌ [Failed](${matchingStatus.url})`;
|
status = `❌ [Failed](${matchingStatus.url})`;
|
||||||
downloadLink = '*Build failed*';
|
downloadLink = '*Build failed*';
|
||||||
@@ -408,7 +439,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
|
commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
commentBody += `\n`;
|
commentBody += `\n`;
|
||||||
|
|||||||
243
.github/workflows/build-apps.yml
vendored
243
.github/workflows/build-apps.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -41,12 +41,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -124,12 +124,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -156,7 +156,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -171,7 +171,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -195,12 +195,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -216,12 +216,13 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.2"
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -236,7 +237,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||||
path: build-*.ipa
|
path: build-*.ipa
|
||||||
@@ -251,7 +252,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -259,12 +260,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -280,9 +281,10 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.2"
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -293,73 +295,136 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
|
||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
# Disabled for now - uncomment when ready to build iOS TV
|
build-ios-tv:
|
||||||
# build-ios-tv:
|
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
# Re-enable by removing the `false &&` prefix below.
|
||||||
# runs-on: macos-26
|
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
# name: 🍎 Build iOS IPA (TV)
|
runs-on: macos-26
|
||||||
# permissions:
|
name: 🍎 Build tvOS IPA
|
||||||
# contents: read
|
permissions:
|
||||||
#
|
contents: read
|
||||||
# steps:
|
|
||||||
# - name: 📥 Checkout code
|
steps:
|
||||||
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- name: 📥 Checkout code
|
||||||
# with:
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
# ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
with:
|
||||||
# fetch-depth: 0
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
# submodules: recursive
|
fetch-depth: 0
|
||||||
# show-progress: false
|
submodules: recursive
|
||||||
#
|
show-progress: false
|
||||||
# - name: 🍞 Setup Bun
|
|
||||||
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
- name: 🍞 Setup Bun
|
||||||
# with:
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
# bun-version: latest
|
with:
|
||||||
#
|
bun-version: latest
|
||||||
# - name: 💾 Cache Bun dependencies
|
|
||||||
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
- name: 💾 Cache Bun dependencies
|
||||||
# with:
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
# path: ~/.bun/install/cache
|
with:
|
||||||
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
path: ~/.bun/install/cache
|
||||||
# restore-keys: |
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
# ${{ runner.os }}-bun-cache
|
restore-keys: |
|
||||||
#
|
${{ runner.os }}-bun-cache
|
||||||
# - name: 📦 Install dependencies and reload submodules
|
|
||||||
# run: |
|
- name: 📦 Install dependencies and reload submodules
|
||||||
# bun install --frozen-lockfile
|
run: |
|
||||||
# bun run submodule-reload
|
bun install --frozen-lockfile
|
||||||
#
|
bun run submodule-reload
|
||||||
# - name: 🛠️ Generate project files
|
|
||||||
# run: bun run prebuild:tv
|
- name: 🛠️ Generate project files
|
||||||
#
|
run: bun run prebuild:tv
|
||||||
# - name: 🔧 Setup Xcode
|
|
||||||
# uses: maxim-lobanov/setup-xcode@v1
|
- name: 🔧 Setup Xcode
|
||||||
# with:
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
# xcode-version: '26.0.1'
|
with:
|
||||||
#
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
# - name: 🏗️ Setup EAS
|
xcode-version: "26.4"
|
||||||
# uses: expo/expo-github-action@main
|
|
||||||
# with:
|
- name: 🏗️ Setup EAS
|
||||||
# eas-version: latest
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
# token: ${{ secrets.EXPO_TOKEN }}
|
with:
|
||||||
# eas-cache: true
|
eas-version: latest
|
||||||
#
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
# - name: 🚀 Build iOS app
|
eas-cache: true
|
||||||
# env:
|
|
||||||
# EXPO_TV: 1
|
- name: 🚀 Build iOS app
|
||||||
# run: eas build -p ios --local --non-interactive
|
env:
|
||||||
#
|
EXPO_TV: 1
|
||||||
# - name: 📅 Set date tag
|
run: eas build -p ios --local --non-interactive
|
||||||
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
|
||||||
#
|
- name: 📅 Set date tag
|
||||||
# - name: 📤 Upload IPA artifact
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
# with:
|
- name: 📤 Upload IPA artifact
|
||||||
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
# path: build-*.ipa
|
with:
|
||||||
# retention-days: 7
|
name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
||||||
|
path: build-*.ipa
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build-ios-tv-unsigned:
|
||||||
|
# Unsigned tvOS build is enabled (compiles without Apple credentials).
|
||||||
|
# The signed tvOS job above stays disabled until tvOS provisioning
|
||||||
|
# profiles are set up in EAS (app + TopShelf targets).
|
||||||
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
|
runs-on: macos-26
|
||||||
|
name: 🍎 Build tvOS IPA (Unsigned)
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
|
with:
|
||||||
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.4"
|
||||||
|
|
||||||
|
- name: 🚀 Build iOS app
|
||||||
|
env:
|
||||||
|
EXPO_TV: 1
|
||||||
|
run: bun run ios:unsigned-build:tv ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
|
||||||
|
|
||||||
|
- name: 📅 Set date tag
|
||||||
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 📤 Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||||
|
path: build/*.ipa
|
||||||
|
retention-days: 7
|
||||||
|
|||||||
6
.github/workflows/check-lockfile.yml
vendored
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -27,12 +27,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
8
.github/workflows/ci-codeql.yml
vendored
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|||||||
4
.github/workflows/crowdin.yml
vendored
4
.github/workflows/crowdin.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
18
.github/workflows/linting.yml
vendored
18
.github/workflows/linting.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
delete: true
|
delete: true
|
||||||
@@ -51,13 +51,13 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
@@ -69,14 +69,14 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
@@ -100,19 +100,19 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
|
|||||||
8
.github/workflows/update-issue-form.yml
vendored
8
.github/workflows/update-issue-form.yml
vendored
@@ -18,17 +18,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: 🔍 Extract minor version from app.json
|
- name: 🔍 Extract minor version from app.json
|
||||||
id: minor
|
id: minor
|
||||||
uses: actions/github-script@main
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
||||||
with:
|
with:
|
||||||
result-encoding: string
|
result-encoding: string
|
||||||
script: |
|
script: |
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
dry_run: no-push
|
dry_run: no-push
|
||||||
|
|
||||||
- name: 📬 Commit and create pull request
|
- name: 📬 Commit and create pull request
|
||||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci-update-bug-report
|
branch: ci-update-bug-report
|
||||||
|
|||||||
6
.gitignore
vendored
6
.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-*
|
||||||
@@ -71,4 +73,6 @@ modules/background-downloader/android/build/*
|
|||||||
|
|
||||||
# ios:unsigned-build Artifacts
|
# ios:unsigned-build Artifacts
|
||||||
build/
|
build/
|
||||||
.claude/settings.local.json
|
.claude/
|
||||||
|
.agents/skills/**
|
||||||
|
skills-lock.json
|
||||||
|
|||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ For the TV version suffix the npm commands with `:tv`.
|
|||||||
`npm run prebuild:tv`
|
`npm run prebuild:tv`
|
||||||
`npm run ios:tv or npm run android:tv`
|
`npm run ios:tv or npm run android:tv`
|
||||||
|
|
||||||
|
TV platform integration notes:
|
||||||
|
|
||||||
|
- [TV Discovery](./docs/tv-discovery.md)
|
||||||
|
|
||||||
## 👋 Get in Touch with Us
|
## 👋 Get in Touch with Us
|
||||||
|
|
||||||
Need assistance or have any questions?
|
Need assistance or have any questions?
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
config.plugins.push([
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
cameraPermission:
|
||||||
|
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
|
|||||||
22
app.json
22
app.json
@@ -2,13 +2,11 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.52.0",
|
"version": "0.54.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"jsEngine": "hermes",
|
|
||||||
"newArchEnabled": true,
|
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"requireFullScreen": true,
|
"requireFullScreen": true,
|
||||||
@@ -38,8 +36,6 @@
|
|||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
|
||||||
"versionCode": 92,
|
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -76,19 +72,21 @@
|
|||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.js",
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
|
"./plugins/withTVUserManagement.js",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6",
|
"deploymentTarget": "16.4",
|
||||||
"useFrameworks": "static"
|
"useFrameworks": "static",
|
||||||
|
"forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
|
||||||
"compileSdkVersion": 36,
|
"compileSdkVersion": 36,
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
"kotlinVersion": "2.0.21",
|
"kotlinVersion": "2.1.20",
|
||||||
"minSdkVersion": 26,
|
"minSdkVersion": 26,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
@@ -135,16 +133,18 @@
|
|||||||
"expo-web-browser",
|
"expo-web-browser",
|
||||||
["./plugins/with-runtime-framework-headers.js"],
|
["./plugins/with-runtime-framework-headers.js"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
|
["./plugins/withAndroidAlertColors.js"],
|
||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.js"],
|
||||||
["./plugins/withTVOSAppIcon.js"],
|
["./plugins/withTVOSAppIcon.js"],
|
||||||
|
["./plugins/withTVOSTopShelf.js"],
|
||||||
["./plugins/withTVXcodeEnv.js"],
|
["./plugins/withTVXcodeEnv.js"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit-GPL",
|
"podName": "MPVKit",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface MenuLink {
|
|||||||
icon: string;
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function menuLinks() {
|
export default function CustomLinksPage() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Favorites } from "@/components/home/Favorites";
|
|||||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
|
||||||
export default function favorites() {
|
export default function FavoritesPage() {
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
const SyncPlayButtonComponent = Platform.isTV
|
||||||
|
? null
|
||||||
|
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
@@ -32,6 +36,7 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
|
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
@@ -47,15 +52,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -66,15 +63,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -96,6 +85,17 @@ export default function IndexLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='companion-login'
|
||||||
|
options={{
|
||||||
|
title: t("companion_login.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => <HeaderBackButton />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/playback-controls/page'
|
name='settings/playback-controls/page'
|
||||||
options={{
|
options={{
|
||||||
@@ -104,15 +104,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -123,15 +115,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -142,15 +126,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -161,15 +137,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -180,15 +148,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -218,15 +178,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -237,15 +189,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -256,15 +200,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -275,15 +211,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -294,15 +222,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -313,15 +233,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -332,15 +244,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
@@ -350,11 +254,7 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
|||||||
7
app/(auth)/(tabs)/(home)/companion-login.tsx
Normal file
7
app/(auth)/(tabs)/(home)/companion-login.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
import { CompanionLoginScreen } from "@/components/companion/CompanionLoginScreen";
|
||||||
|
|
||||||
|
export default function CompanionLoginPage() {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
return <CompanionLoginScreen />;
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
|||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function page() {
|
export default function DownloadsPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [_queue, _setQueue] = useAtom(queueAtom);
|
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
|
|||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
|
||||||
export default function page() {
|
export default function SessionsPage() {
|
||||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getProgressPercentage = () => {
|
const getProgressPercentage = () => {
|
||||||
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
if (!session.NowPlayingItem?.RunTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ function SettingsMobile() {
|
|||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
|
<View className='mb-4'>
|
||||||
|
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() =>
|
||||||
|
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||||
|
}
|
||||||
|
title={t("pairing.pair_with_phone")}
|
||||||
|
textColor='blue'
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Directory, Paths } from "expo-file-system";
|
||||||
|
import { Image } from "expo-image";
|
||||||
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,
|
||||||
@@ -17,28 +22,240 @@ import {
|
|||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||||
|
import { APP_LANGUAGES } from "@/i18n";
|
||||||
|
import { clearCache as clearAudioCache } from "@/providers/AudioStorage";
|
||||||
|
import {
|
||||||
|
apiAtom,
|
||||||
|
cacheVersionAtom,
|
||||||
|
useJellyfin,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
AudioTranscodeMode,
|
AudioTranscodeMode,
|
||||||
|
InactivityTimeout,
|
||||||
|
type MpvCacheMode,
|
||||||
|
type MpvVoDriver,
|
||||||
TVTypographyScale,
|
TVTypographyScale,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/utils/atoms/settings";
|
} from "@/utils/atoms/settings";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import {
|
||||||
|
getPreviousServers,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
||||||
|
|
||||||
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 [, setCacheVersion] = useAtom(cacheVersionAtom);
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clearing all cache in the entire app
|
||||||
|
const handleClearCache = async () => {
|
||||||
|
Alert.alert(
|
||||||
|
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
|
||||||
|
t(
|
||||||
|
"home.settings.storage.clear_all_cache_confirm_desc",
|
||||||
|
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("common.cancel", "Cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.ok", "OK"),
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
// 1. Clear React Query Cache (memory & MMKV)
|
||||||
|
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
||||||
|
await queryClient.resetQueries();
|
||||||
|
|
||||||
|
// 2. Clear expo-image cache (memory & disk)
|
||||||
|
await Image.clearDiskCache();
|
||||||
|
Image.clearMemoryCache();
|
||||||
|
|
||||||
|
// 3. Clear AudioStorage (music) cache
|
||||||
|
await clearAudioCache();
|
||||||
|
|
||||||
|
// 4. Clear TopShelf cache
|
||||||
|
clearTopShelfCacheSafely();
|
||||||
|
|
||||||
|
// 5. Clear Subtitle Cache
|
||||||
|
storage.remove("downloadedSubtitles.json");
|
||||||
|
const subtitlesDir = new Directory(
|
||||||
|
Paths.cache,
|
||||||
|
"streamyfin-subtitles",
|
||||||
|
);
|
||||||
|
if (subtitlesDir.exists) {
|
||||||
|
await subtitlesDir.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Clear MMKV caches like extracted image colors and other non-essential storage keys
|
||||||
|
const keysToKeep = [
|
||||||
|
"settings",
|
||||||
|
"serverUrl",
|
||||||
|
"token",
|
||||||
|
"user",
|
||||||
|
"deviceId",
|
||||||
|
"previousServers",
|
||||||
|
"hasAskedForNotificationPermission",
|
||||||
|
"hasShownIntro",
|
||||||
|
"multiAccountMigrated",
|
||||||
|
"selectedTVServer",
|
||||||
|
"downloads.v2.json",
|
||||||
|
];
|
||||||
|
const allKeys = storage.getAllKeys();
|
||||||
|
for (const key of allKeys) {
|
||||||
|
if (!keysToKeep.includes(key)) {
|
||||||
|
storage.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Increment cache version to force remount of components
|
||||||
|
setCacheVersion((v) => v + 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear cache:", error);
|
||||||
|
Alert.alert(
|
||||||
|
t("home.settings.toasts.error_deleting_files", "Error"),
|
||||||
|
t(
|
||||||
|
"home.settings.storage.clear_all_cache_error_desc",
|
||||||
|
"An error occurred while clearing the cache.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const currentAudioTranscode =
|
const currentAudioTranscode =
|
||||||
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
|
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
|
||||||
const currentSubtitleMode =
|
const currentSubtitleMode =
|
||||||
@@ -47,6 +264,9 @@ export default function SettingsTV() {
|
|||||||
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||||
const currentTypographyScale =
|
const currentTypographyScale =
|
||||||
settings.tvTypographyScale || TVTypographyScale.Default;
|
settings.tvTypographyScale || TVTypographyScale.Default;
|
||||||
|
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
|
||||||
|
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
||||||
|
const currentLanguage = settings.preferedLanguage;
|
||||||
|
|
||||||
// Audio transcoding options
|
// Audio transcoding options
|
||||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
@@ -138,26 +358,65 @@ 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// VO driver options
|
||||||
|
const voDriverOptions: TVOptionItem<MpvVoDriver>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.vo_driver.gpu_next"),
|
||||||
|
value: "gpu-next",
|
||||||
|
selected: currentVoDriver === "gpu-next",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.vo_driver.gpu"),
|
||||||
|
value: "gpu",
|
||||||
|
selected: currentVoDriver === "gpu",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentVoDriver],
|
||||||
|
);
|
||||||
|
|
||||||
// Typography scale options
|
// Typography scale options
|
||||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
label: t("home.settings.appearance.text_size_small"),
|
label: t("home.settings.appearance.display_size_small"),
|
||||||
value: TVTypographyScale.Small,
|
value: TVTypographyScale.Small,
|
||||||
selected: currentTypographyScale === TVTypographyScale.Small,
|
selected: currentTypographyScale === TVTypographyScale.Small,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("home.settings.appearance.text_size_default"),
|
label: t("home.settings.appearance.display_size_default"),
|
||||||
value: TVTypographyScale.Default,
|
value: TVTypographyScale.Default,
|
||||||
selected: currentTypographyScale === TVTypographyScale.Default,
|
selected: currentTypographyScale === TVTypographyScale.Default,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("home.settings.appearance.text_size_large"),
|
label: t("home.settings.appearance.display_size_large"),
|
||||||
value: TVTypographyScale.Large,
|
value: TVTypographyScale.Large,
|
||||||
selected: currentTypographyScale === TVTypographyScale.Large,
|
selected: currentTypographyScale === TVTypographyScale.Large,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("home.settings.appearance.text_size_extra_large"),
|
label: t("home.settings.appearance.display_size_extra_large"),
|
||||||
value: TVTypographyScale.ExtraLarge,
|
value: TVTypographyScale.ExtraLarge,
|
||||||
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
|
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
|
||||||
},
|
},
|
||||||
@@ -165,6 +424,74 @@ export default function SettingsTV() {
|
|||||||
[t, currentTypographyScale],
|
[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);
|
||||||
@@ -188,9 +515,32 @@ export default function SettingsTV() {
|
|||||||
|
|
||||||
const typographyScaleLabel = useMemo(() => {
|
const typographyScaleLabel = useMemo(() => {
|
||||||
const option = typographyScaleOptions.find((o) => o.selected);
|
const option = typographyScaleOptions.find((o) => o.selected);
|
||||||
return option?.label || t("home.settings.appearance.text_size_default");
|
return option?.label || t("home.settings.appearance.display_size_default");
|
||||||
}, [typographyScaleOptions, t]);
|
}, [typographyScaleOptions, t]);
|
||||||
|
|
||||||
|
const cacheModeLabel = useMemo(() => {
|
||||||
|
const option = cacheModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.buffer.cache_auto");
|
||||||
|
}, [cacheModeOptions, t]);
|
||||||
|
|
||||||
|
const voDriverLabel = useMemo(() => {
|
||||||
|
const option = voDriverOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.vo_driver.gpu_next");
|
||||||
|
}, [voDriverOptions, 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 }}>
|
||||||
@@ -215,6 +565,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
|
||||||
@@ -228,7 +603,6 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ audioTranscodeMode: value }),
|
updateSettings({ audioTranscodeMode: value }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
isFirst
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Subtitles Section */}
|
{/* Subtitles Section */}
|
||||||
@@ -255,26 +629,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({
|
||||||
@@ -283,7 +641,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({
|
||||||
@@ -382,20 +740,117 @@ 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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Video Output Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.vo_driver.vo_mode")}
|
||||||
|
value={voDriverLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.vo_driver.vo_mode"),
|
||||||
|
options: voDriverOptions,
|
||||||
|
onSelect: (value) => updateSettings({ mpvVoDriver: 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
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.appearance.text_size")}
|
label={t("home.settings.appearance.display_size")}
|
||||||
value={typographyScaleLabel}
|
value={typographyScaleLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("home.settings.appearance.text_size"),
|
title: t("home.settings.appearance.display_size"),
|
||||||
options: typographyScaleOptions,
|
options: typographyScaleOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({ tvTypographyScale: 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",
|
||||||
@@ -422,6 +877,20 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ showSeriesPosterOnEpisode: value })
|
updateSettings({ showSeriesPosterOnEpisode: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.theme_music")}
|
||||||
|
value={settings.tvThemeMusicEnabled}
|
||||||
|
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Storage Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.storage.clear_all_cache")}
|
||||||
|
value=''
|
||||||
|
onPress={handleClearCache}
|
||||||
|
isFirst
|
||||||
|
/>
|
||||||
|
|
||||||
{/* User Section */}
|
{/* User Section */}
|
||||||
<TVSectionHeader
|
<TVSectionHeader
|
||||||
@@ -444,6 +913,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function AppearanceHideLibrariesPage() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function HideLibrariesPage() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ export default function Page() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
logsFile.write(JSON.stringify(filteredLogs));
|
logsFile.write(JSON.stringify(filteredLogs));
|
||||||
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
await Sharing.shareAsync(logsFile.uri, {
|
||||||
|
mimeType: "text/plain",
|
||||||
|
UTI: "public.plain-text",
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
writeErrorLog("Something went wrong attempting to export", e);
|
writeErrorLog("Something went wrong attempting to export", e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -85,12 +88,7 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View className='flex-1'>
|
||||||
className='flex-1'
|
|
||||||
style={{
|
|
||||||
paddingTop: insets.top + 48,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
@@ -114,7 +112,10 @@ export default function Page() {
|
|||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView className='pb-4 px-4'>
|
<ScrollView
|
||||||
|
className='pb-4 px-4'
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||||
|
>
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
{filteredLogs?.map((log, index) => (
|
{filteredLogs?.map((log, index) => (
|
||||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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 { MpvVoSettings } from "@/components/settings/MpvVoSettings";
|
||||||
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 +28,8 @@ export default function PlaybackControlsPage() {
|
|||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
<PlaybackControlsSettings />
|
<PlaybackControlsSettings />
|
||||||
|
<MpvBufferSettings />
|
||||||
|
<MpvVoSettings />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function JellyseerrPluginPage() {
|
||||||
const { pluginSettings } = useSettings();
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
className='px-4'
|
className='p-4'
|
||||||
>
|
>
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function KefinTweaksPage() {
|
||||||
const { pluginSettings } = useSettings();
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||||
className='px-4'
|
className='p-4'
|
||||||
>
|
>
|
||||||
<KefinTweaksSettings />
|
<KefinTweaksSettings />
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function MarlinSearchPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem";
|
|||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function StreamystatsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -27,16 +27,11 @@ 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 } 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 {
|
|
||||||
TVFilterButton,
|
|
||||||
TVFocusablePoster,
|
|
||||||
TVItemCardText,
|
|
||||||
} from "@/components/tv";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
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";
|
||||||
@@ -60,11 +55,13 @@ 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 router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
const [orientation, _setOrientation] = useState(
|
const [orientation, _setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
@@ -153,7 +150,7 @@ const page: React.FC = () => {
|
|||||||
// Calculate columns for TV grid
|
// Calculate columns for TV grid
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
|
||||||
return Math.max(
|
return Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||||
@@ -189,7 +186,7 @@ const page: React.FC = () => {
|
|||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||||
includeItemTypes: ["Movie", "Series"],
|
includeItemTypes: ["Movie", "Series", "Season"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -291,23 +288,19 @@ const page: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
marginRight: TV_ITEM_GAP,
|
marginRight: TV_ITEM_GAP,
|
||||||
marginBottom: TV_ITEM_GAP,
|
marginBottom: TV_ITEM_GAP,
|
||||||
width: TV_POSTER_WIDTH,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVFocusablePoster onPress={handlePress}>
|
<TVPosterCard
|
||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
item={item}
|
||||||
{(item.Type === "Series" || item.Type === "Episode") && (
|
orientation='vertical'
|
||||||
<SeriesPoster item={item} />
|
onPress={handlePress}
|
||||||
)}
|
onLongPress={() => showItemActions(item)}
|
||||||
{item.Type !== "Movie" &&
|
width={posterSizes.poster}
|
||||||
item.Type !== "Series" &&
|
/>
|
||||||
item.Type !== "Episode" && <MoviePoster item={item} />}
|
|
||||||
</TVFocusablePoster>
|
|
||||||
<TVItemCardText item={item} />
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[router],
|
[router, showItemActions, posterSizes.poster],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ const Page: React.FC = () => {
|
|||||||
ItemFields.MediaStreams,
|
ItemFields.MediaStreams,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Lazily preload item with full media sources in background
|
// Lazily preload item with full media sources in background — never cache
|
||||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
|
||||||
|
gcTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
|
||||||
export default function page() {
|
export default function JellyseerrCompanyPage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
|||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
|
||||||
export default function page() {
|
export default function JellyseerrGenrePage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
|||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
|
||||||
export default function page() {
|
export default function JellyseerrPersonPage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import { Slot, Stack, withLayoutContext } from "expo-router";
|
||||||
import {
|
import {
|
||||||
createMaterialTopTabNavigator,
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "@react-navigation/material-top-tabs";
|
} from "expo-router/js-top-tabs";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "expo-router/react-navigation";
|
||||||
import { 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" }} />
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function LiveTvChannelsPage() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const _insets = useSafeAreaInsets();
|
const _insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
|
|||||||
|
|
||||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||||
|
|
||||||
export default function page() {
|
export default function LiveTvGuidePage() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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";
|
||||||
|
|
||||||
export default function page() {
|
export default function LiveTvRecordingsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View className='flex items-center justify-center h-full -mt-12'>
|
<View className='flex items-center justify-center h-full -mt-12'>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 { 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, { useCallback, useEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
@@ -33,18 +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 {
|
|
||||||
TVFilterButton,
|
|
||||||
TVFocusablePoster,
|
|
||||||
TVItemCardText,
|
|
||||||
} from "@/components/tv";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
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";
|
||||||
@@ -70,10 +67,12 @@ import {
|
|||||||
} 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 type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
const TV_ITEM_GAP = 20;
|
const TV_ITEM_GAP = 20;
|
||||||
const TV_HORIZONTAL_PADDING = 60;
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
const _TV_SCALE_PADDING = 20;
|
const _TV_SCALE_PADDING = 20;
|
||||||
|
const TV_PLAYLIST_SQUARE_SIZE = 180;
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams() as {
|
const searchParams = useLocalSearchParams() as {
|
||||||
@@ -85,6 +84,7 @@ const Page = () => {
|
|||||||
const { libraryId } = searchParams;
|
const { libraryId } = searchParams;
|
||||||
|
|
||||||
const typography = useScaledTVTypography();
|
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();
|
||||||
@@ -105,9 +105,14 @@ const Page = () => {
|
|||||||
|
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
|
// Fallback refresh for newly added content when returning to the library
|
||||||
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
|
useRefreshLibraryOnFocus();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
// TV Filter queries
|
// TV Filter queries
|
||||||
const { data: tvGenreOptions } = useQuery({
|
const { data: tvGenreOptions } = useQuery({
|
||||||
@@ -286,6 +291,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({
|
||||||
@@ -305,6 +312,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;
|
||||||
@@ -401,31 +411,82 @@ const Page = () => {
|
|||||||
const renderTVItem = useCallback(
|
const renderTVItem = useCallback(
|
||||||
(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
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
item={item}
|
||||||
width: TV_POSTER_WIDTH,
|
orientation='vertical'
|
||||||
}}
|
onPress={handlePress}
|
||||||
>
|
onLongPress={() => showItemActions(item)}
|
||||||
<TVFocusablePoster onPress={handlePress}>
|
width={posterSizes.poster}
|
||||||
{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],
|
[router, showItemActions, api, typography],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||||
import {
|
import {
|
||||||
createMaterialTopTabNavigator,
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "@react-navigation/material-top-tabs";
|
} from "expo-router/js-top-tabs";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "expo-router/react-navigation";
|
||||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useRoute } from "@react-navigation/native";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useRoute } from "expo-router/react-navigation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useRoute } from "@react-navigation/native";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useRoute } from "expo-router/react-navigation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useNavigation, useRoute } from "expo-router/react-navigation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useRoute } from "@react-navigation/native";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useRoute } from "expo-router/react-navigation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -65,10 +66,11 @@ const exampleSearches = [
|
|||||||
"The Mandalorian",
|
"The Mandalorian",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function search() {
|
export default function SearchPage() {
|
||||||
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)";
|
||||||
|
|
||||||
@@ -219,7 +221,7 @@ export default function search() {
|
|||||||
|
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
if (!ids || !ids.length) {
|
if (!ids?.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +305,9 @@ export default function search() {
|
|||||||
},
|
},
|
||||||
hideWhenScrolling: false,
|
hideWhenScrolling: false,
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
|
// Android: placeholder and icon color
|
||||||
|
hintTextColor: "#fff",
|
||||||
|
headerIconColor: "#fff",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
@@ -607,6 +612,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}
|
||||||
@@ -935,7 +941,7 @@ export default function search() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : debouncedSearch.length === 0 ? (
|
) : debouncedSearch.length === 0 ? (
|
||||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
<View className='mt-2 flex flex-col items-center space-y-2'>
|
||||||
{exampleSearches.map((e) => (
|
{exampleSearches.map((e) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -24,14 +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 SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import {
|
import {
|
||||||
useDeleteWatchlist,
|
useDeleteWatchlist,
|
||||||
useRemoveFromWatchlist,
|
useRemoveFromWatchlist,
|
||||||
@@ -46,35 +44,12 @@ import { userAtom } from "@/providers/JellyfinProvider";
|
|||||||
const TV_ITEM_GAP = 20;
|
const TV_ITEM_GAP = 20;
|
||||||
const TV_HORIZONTAL_PADDING = 60;
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
|
||||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{
|
|
||||||
item: BaseItemDto;
|
|
||||||
typography: Typography;
|
|
||||||
}> = ({ item, typography }) => (
|
|
||||||
<View style={{ marginTop: 12 }}>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: typography.callout - 2,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function WatchlistDetailScreen() {
|
export default function WatchlistDetailScreen() {
|
||||||
const typography = useScaledTVTypography();
|
const 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 }>();
|
||||||
@@ -203,26 +178,18 @@ export default function WatchlistDetailScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<TVPosterCard
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
item={item}
|
||||||
width: TV_POSTER_WIDTH,
|
orientation='vertical'
|
||||||
}}
|
onPress={handlePress}
|
||||||
>
|
onLongPress={() => showItemActions(item)}
|
||||||
<TVFocusablePoster
|
hasTVPreferredFocus={index === 0}
|
||||||
onPress={handlePress}
|
width={posterSizes.poster}
|
||||||
hasTVPreferredFocus={index === 0}
|
/>
|
||||||
>
|
|
||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
|
||||||
{(item.Type === "Series" || item.Type === "Episode") && (
|
|
||||||
<SeriesPoster item={item} />
|
|
||||||
)}
|
|
||||||
</TVFocusablePoster>
|
|
||||||
<TVItemCardText item={item} typography={typography} />
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[router, typography],
|
[router, showItemActions, posterSizes.poster],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
import { withLayoutContext } from "expo-router";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "expo-router/react-navigation";
|
||||||
import { withLayoutContext } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useTVHomeBackHandler } 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
|
||||||
|
useTVHomeBackHandler();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
@@ -130,7 +134,7 @@ export default function TabLayout() {
|
|||||||
tabBarItemHidden: !Platform.isTV,
|
tabBarItemHidden: !Platform.isTV,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
|
||||||
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,22 +46,25 @@ 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 { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import type { PlayerControls } from "@/providers/SyncPlay/types";
|
||||||
|
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 { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
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 DirectPlayerPage() {
|
||||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -105,6 +109,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");
|
||||||
@@ -122,6 +129,7 @@ export default function page() {
|
|||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
playbackPosition: playbackPositionFromUrl,
|
playbackPosition: playbackPositionFromUrl,
|
||||||
|
syncPlay: syncPlayStr,
|
||||||
} = useLocalSearchParams<{
|
} = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
@@ -131,9 +139,24 @@ export default function page() {
|
|||||||
offline: string;
|
offline: string;
|
||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
|
/** Whether playback was initiated by SyncPlay */
|
||||||
|
syncPlay?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
|
||||||
|
const openedViaSyncPlay = syncPlayStr === "true";
|
||||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
|
// SyncPlay integration
|
||||||
|
const syncPlay = useSyncPlay();
|
||||||
|
const {
|
||||||
|
isEnabled: isSyncPlayEnabled,
|
||||||
|
controller: syncPlayController,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
} = syncPlay;
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
@@ -149,6 +172,13 @@ export default function page() {
|
|||||||
: BITRATES[0].value;
|
: BITRATES[0].value;
|
||||||
|
|
||||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||||
|
const initialSeekDoneRef = useRef(false);
|
||||||
|
|
||||||
|
const initialPlaybackTicksRef = useRef<number>(
|
||||||
|
playbackPositionFromUrl
|
||||||
|
? Number.parseInt(playbackPositionFromUrl, 10)
|
||||||
|
: (item?.UserData?.PlaybackPositionTicks ?? 0),
|
||||||
|
);
|
||||||
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
|
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -172,11 +202,11 @@ export default function page() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
||||||
|
|
||||||
// Initialize TV audio/subtitle indices from URL params
|
// Initialize TV audio/subtitle indices from URL params.
|
||||||
|
// No undefined guard: when a new episode's URL omits audioIndex, reset to
|
||||||
|
// undefined (media default) rather than leaking the previous episode's track.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioIndex !== undefined) {
|
setCurrentAudioIndex(audioIndex);
|
||||||
setCurrentAudioIndex(audioIndex);
|
|
||||||
}
|
|
||||||
}, [audioIndex]);
|
}, [audioIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -209,12 +239,25 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Gets the initial playback position from the URL. */
|
/** Gets the initial playback position from the URL. */
|
||||||
const getInitialPlaybackTicks = useCallback((): number => {
|
// const getInitialPlaybackTicks = useCallback((): number => {
|
||||||
if (playbackPositionFromUrl) {
|
// if (playbackPositionFromUrl) {
|
||||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
// return Number.parseInt(playbackPositionFromUrl, 10);
|
||||||
|
// }
|
||||||
|
// return item?.UserData?.PlaybackPositionTicks ?? 0;
|
||||||
|
// }, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tracksReady || !videoRef.current) return;
|
||||||
|
if (initialSeekDoneRef.current) return;
|
||||||
|
|
||||||
|
initialSeekDoneRef.current = true;
|
||||||
|
|
||||||
|
const ticks = initialPlaybackTicksRef.current;
|
||||||
|
|
||||||
|
if (ticks > 0) {
|
||||||
|
videoRef.current.seekTo(ticksToSeconds(ticks));
|
||||||
}
|
}
|
||||||
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
}, [tracksReady]);
|
||||||
}, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchItemData = async () => {
|
const fetchItemData = async () => {
|
||||||
@@ -228,7 +271,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 +310,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);
|
||||||
@@ -285,7 +334,7 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline && downloadedItem && downloadedItem.mediaSource) {
|
if (offline && downloadedItem?.mediaSource) {
|
||||||
const url = downloadedItem.videoFilePath;
|
const url = downloadedItem.videoFilePath;
|
||||||
if (item) {
|
if (item) {
|
||||||
result = {
|
result = {
|
||||||
@@ -324,7 +373,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 +382,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 });
|
||||||
@@ -371,8 +420,102 @@ export default function page() {
|
|||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream, api, offline]);
|
}, [stream, api, offline]);
|
||||||
|
|
||||||
|
// SyncPlay: Connect player controls when video is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVideoLoaded || !videoRef.current || offline) {
|
||||||
|
setPlayerControls(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls: PlayerControls = {
|
||||||
|
play: () => videoRef.current?.play(),
|
||||||
|
pause: () => videoRef.current?.pause(),
|
||||||
|
seekTo: (positionMs: number) => {
|
||||||
|
const positionSec = positionMs / 1000;
|
||||||
|
console.log(
|
||||||
|
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
|
||||||
|
);
|
||||||
|
videoRef.current?.seekTo(positionSec);
|
||||||
|
},
|
||||||
|
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
|
||||||
|
getSpeed: () => currentPlaybackSpeed,
|
||||||
|
getCurrentPosition: () => progress.get(),
|
||||||
|
isPlaying: () => isPlaying,
|
||||||
|
isBuffering: () => isBuffering,
|
||||||
|
};
|
||||||
|
|
||||||
|
setPlayerControls(controls);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setPlayerControls(null);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isVideoLoaded,
|
||||||
|
offline,
|
||||||
|
isPlaying,
|
||||||
|
isBuffering,
|
||||||
|
currentPlaybackSpeed,
|
||||||
|
progress,
|
||||||
|
setPlayerControls,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// SyncPlay: Report buffering/ready state to server.
|
||||||
|
//
|
||||||
|
// CRITICAL: We must report `buffering` to the server *during* initial
|
||||||
|
// load (before `isVideoLoaded`), otherwise the server treats us as ready
|
||||||
|
// and proceeds without waiting for us. jellyfin-web reports this for
|
||||||
|
// free via the HTML5 video element's `waiting` event; for us, the
|
||||||
|
// initial load itself is the buffering window.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSyncPlayEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocallyReady = isVideoLoaded && !isBuffering;
|
||||||
|
if (isLocallyReady) {
|
||||||
|
notifyReady();
|
||||||
|
} else {
|
||||||
|
notifyBuffering();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
isVideoLoaded,
|
||||||
|
isBuffering,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// SyncPlay: Pause playback when group is waiting
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSyncPlayEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupState = syncPlay.groupInfo?.State;
|
||||||
|
const isLocalReady = isVideoLoaded && !isBuffering;
|
||||||
|
const isWaitingForGroup = groupState === "Waiting";
|
||||||
|
|
||||||
|
// Pause playback when waiting for group
|
||||||
|
if (isLocalReady && isWaitingForGroup && isPlaying) {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlay.groupInfo?.State,
|
||||||
|
isVideoLoaded,
|
||||||
|
isBuffering,
|
||||||
|
isPlaying,
|
||||||
|
]);
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Route through SyncPlay when active
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.playPause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
@@ -420,7 +563,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);
|
||||||
@@ -436,8 +581,11 @@ export default function page() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ItemId: item.Id,
|
ItemId: item.Id,
|
||||||
AudioStreamIndex: audioIndex ? audioIndex : undefined,
|
// Report the live selection so server-side session/resume state reflects
|
||||||
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
// mid-playback track changes. Note: index 0 is valid (don't treat as
|
||||||
|
// falsy); -1 means "off" and is reported as-is.
|
||||||
|
AudioStreamIndex: currentAudioIndex,
|
||||||
|
SubtitleStreamIndex: currentSubtitleIndex,
|
||||||
MediaSourceId: mediaSourceId,
|
MediaSourceId: mediaSourceId,
|
||||||
PositionTicks: msToTicks(progress.get()),
|
PositionTicks: msToTicks(progress.get()),
|
||||||
IsPaused: !isPlaying,
|
IsPaused: !isPlaying,
|
||||||
@@ -451,8 +599,8 @@ export default function page() {
|
|||||||
}, [
|
}, [
|
||||||
stream,
|
stream,
|
||||||
item?.Id,
|
item?.Id,
|
||||||
audioIndex,
|
currentAudioIndex,
|
||||||
subtitleIndex,
|
currentSubtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
progress,
|
progress,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
@@ -519,8 +667,8 @@ export default function page() {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
item?.Id,
|
item?.Id,
|
||||||
audioIndex,
|
currentAudioIndex,
|
||||||
subtitleIndex,
|
currentSubtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
stream,
|
stream,
|
||||||
@@ -530,10 +678,30 @@ export default function page() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Gets the initial playback position in seconds. */
|
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
|
||||||
const _startPosition = useMemo(() => {
|
const nowPlayingMetadata = useMemo(() => {
|
||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
if (!item || !api) return undefined;
|
||||||
}, [getInitialPlaybackTicks]);
|
|
||||||
|
const artworkUri = getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: item.Name || "",
|
||||||
|
artist:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? item.SeriesName || ""
|
||||||
|
: item.AlbumArtist || "",
|
||||||
|
albumTitle:
|
||||||
|
item.Type === "Episode" && item.SeasonName
|
||||||
|
? item.SeasonName
|
||||||
|
: undefined,
|
||||||
|
artworkUri: artworkUri || undefined,
|
||||||
|
};
|
||||||
|
}, [item, api]);
|
||||||
|
|
||||||
/** Build video source config for MPV */
|
/** Build video source config for MPV */
|
||||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||||
@@ -581,12 +749,23 @@ export default function page() {
|
|||||||
const startPos = ticksToSeconds(startTicks);
|
const startPos = ticksToSeconds(startTicks);
|
||||||
|
|
||||||
// Build source config - headers only needed for online streaming
|
// Build source config - headers only needed for online streaming
|
||||||
|
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
|
||||||
|
const shouldAutoplay = !openedViaSyncPlay;
|
||||||
const source: MpvVideoSource = {
|
const source: MpvVideoSource = {
|
||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: shouldAutoplay,
|
||||||
initialSubtitleId,
|
initialSubtitleId,
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
|
// Pass cache/buffer settings from user preferences
|
||||||
|
cacheConfig: {
|
||||||
|
enabled: settings.mpvCacheEnabled,
|
||||||
|
cacheSeconds: settings.mpvCacheSeconds,
|
||||||
|
maxBytes: settings.mpvDemuxerMaxBytes,
|
||||||
|
maxBackBytes: settings.mpvDemuxerMaxBackBytes,
|
||||||
|
},
|
||||||
|
// Pass VO driver setting (Android only)
|
||||||
|
voDriver: settings.mpvVoDriver,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add external subtitles only for online playback
|
// Add external subtitles only for online playback
|
||||||
@@ -594,17 +773,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 +806,11 @@ export default function page() {
|
|||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
|
settings.mpvCacheEnabled,
|
||||||
|
settings.mpvCacheSeconds,
|
||||||
|
settings.mpvDemuxerMaxBytes,
|
||||||
|
settings.mpvDemuxerMaxBackBytes,
|
||||||
|
settings.mpvVoDriver,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
@@ -702,23 +901,27 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
await activateKeepAwakeAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
await deactivateKeepAwake();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,15 +929,19 @@ export default function page() {
|
|||||||
setIsBuffering(isLoading);
|
setIsBuffering(isLoading);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackManager, item?.Id, progress],
|
[
|
||||||
|
playbackManager,
|
||||||
|
item?.Id,
|
||||||
|
progress,
|
||||||
|
pauseInactivityTimer,
|
||||||
|
resumeInactivityTimer,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** PiP handler for MPV */
|
|
||||||
const _onPictureInPictureChange = useCallback(
|
const _onPictureInPictureChange = useCallback(
|
||||||
(e: { nativeEvent: { isActive: boolean } }) => {
|
(e: { nativeEvent: { isActive: boolean } }) => {
|
||||||
const { isActive } = e.nativeEvent;
|
const { isActive } = e.nativeEvent;
|
||||||
setIsPipMode(isActive);
|
setIsPipMode(isActive);
|
||||||
// Hide controls when entering PiP
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
_setShowControls(false);
|
_setShowControls(false);
|
||||||
}
|
}
|
||||||
@@ -752,6 +959,9 @@ export default function page() {
|
|||||||
|
|
||||||
// Memoize video ref functions to prevent unnecessary re-renders
|
// Memoize video ref functions to prevent unnecessary re-renders
|
||||||
const startPictureInPicture = useCallback(async () => {
|
const startPictureInPicture = useCallback(async () => {
|
||||||
|
// Hide controls BEFORE entering PiP so the window captures a clean view
|
||||||
|
_setShowControls(false);
|
||||||
|
setIsPipMode(true);
|
||||||
return videoRef.current?.startPictureInPicture?.();
|
return videoRef.current?.startPictureInPicture?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -763,10 +973,20 @@ export default function page() {
|
|||||||
videoRef.current?.pause?.();
|
videoRef.current?.pause?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback((position: number) => {
|
const seek = useCallback(
|
||||||
// MPV expects seconds, convert from ms
|
(position: number) => {
|
||||||
videoRef.current?.seekTo?.(position / 1000);
|
// Route through SyncPlay when active
|
||||||
}, []);
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
console.log("SyncPlay: seek requested via SyncPlay", position);
|
||||||
|
syncPlayController.seekMs(position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MPV expects seconds, convert from ms
|
||||||
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
|
},
|
||||||
|
[isSyncPlayEnabled, syncPlayController],
|
||||||
|
);
|
||||||
|
|
||||||
// TV audio track change handler
|
// TV audio track change handler
|
||||||
const handleAudioIndexChange = useCallback(
|
const handleAudioIndexChange = useCallback(
|
||||||
@@ -916,8 +1136,9 @@ export default function page() {
|
|||||||
subtitleIndex: defaultSubtitleIndex,
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
} = getDefaultPlaySettings(previousItem, settings, {
|
} = getDefaultPlaySettings(previousItem, settings, {
|
||||||
indexes: {
|
indexes: {
|
||||||
subtitleIndex: subtitleIndex,
|
// Use the live selection, not the stale URL params (see goToNextItem).
|
||||||
audioIndex: audioIndex,
|
subtitleIndex: currentSubtitleIndex,
|
||||||
|
audioIndex: currentAudioIndex,
|
||||||
},
|
},
|
||||||
source: stream?.mediaSource ?? undefined,
|
source: stream?.mediaSource ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -936,8 +1157,8 @@ export default function page() {
|
|||||||
}, [
|
}, [
|
||||||
previousItem,
|
previousItem,
|
||||||
settings,
|
settings,
|
||||||
subtitleIndex,
|
currentSubtitleIndex,
|
||||||
audioIndex,
|
currentAudioIndex,
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
@@ -974,7 +1195,7 @@ export default function page() {
|
|||||||
|
|
||||||
// TV: Navigate to next item
|
// TV: Navigate to next item
|
||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
if (!nextItem || !settings) return;
|
if (!nextItem || !settings || isPlaybackStopped) return;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mediaSource: newMediaSource,
|
mediaSource: newMediaSource,
|
||||||
@@ -982,8 +1203,10 @@ export default function page() {
|
|||||||
subtitleIndex: defaultSubtitleIndex,
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
} = getDefaultPlaySettings(nextItem, settings, {
|
} = getDefaultPlaySettings(nextItem, settings, {
|
||||||
indexes: {
|
indexes: {
|
||||||
subtitleIndex: subtitleIndex,
|
// Use the live selection (updated when the user changes tracks
|
||||||
audioIndex: audioIndex,
|
// mid-playback), not the stale URL params the episode started with.
|
||||||
|
subtitleIndex: currentSubtitleIndex,
|
||||||
|
audioIndex: currentAudioIndex,
|
||||||
},
|
},
|
||||||
source: stream?.mediaSource ?? undefined,
|
source: stream?.mediaSource ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -1002,11 +1225,12 @@ export default function page() {
|
|||||||
}, [
|
}, [
|
||||||
nextItem,
|
nextItem,
|
||||||
settings,
|
settings,
|
||||||
subtitleIndex,
|
currentSubtitleIndex,
|
||||||
audioIndex,
|
currentAudioIndex,
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
|
isPlaybackStopped,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
@@ -1028,14 +1252,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 +1293,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 (
|
||||||
@@ -1115,8 +1374,10 @@ export default function page() {
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
|
onPictureInPictureChange={_onPictureInPictureChange}
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
@@ -1180,6 +1441,7 @@ export default function page() {
|
|||||||
getTechnicalInfo={getTechnicalInfo}
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
playMethod={playMethod}
|
playMethod={playMethod}
|
||||||
transcodeReasons={transcodeReasons}
|
transcodeReasons={transcodeReasons}
|
||||||
|
downloadedFiles={downloadedFiles}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Controls
|
<Controls
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -11,13 +11,17 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVOptionCard } from "@/components/tv";
|
import { TVOptionCard } from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
|
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
export default function TVOptionModal() {
|
export default function TVOptionModal() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modalState = useAtomValue(tvOptionModalAtom);
|
const modalState = useAtomValue(tvOptionModalAtom);
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const firstCardRef = useRef<View>(null);
|
const firstCardRef = useRef<View>(null);
|
||||||
@@ -76,12 +80,25 @@ export default function TVOptionModal() {
|
|||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
store.set(tvOptionModalAtom, null);
|
||||||
|
router.back();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// Intercept back/menu press to close the modal instead of the player
|
||||||
|
useTVBackPress(() => {
|
||||||
|
handleClose();
|
||||||
|
return true;
|
||||||
|
}, [handleClose]);
|
||||||
|
|
||||||
// If no modal state, just go back (shouldn't happen in normal usage)
|
// If no modal state, just go back (shouldn't happen in normal usage)
|
||||||
if (!modalState) {
|
if (!modalState) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, options, cardWidth = 160, cardHeight = 75 } = modalState;
|
const { title, options } = modalState;
|
||||||
|
const scaledCardWidth = scaleSize(160);
|
||||||
|
const scaledCardHeight = scaleSize(75);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
@@ -100,7 +117,9 @@ export default function TVOptionModal() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={[styles.title, { fontSize: typography.callout }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
@@ -119,8 +138,8 @@ export default function TVOptionModal() {
|
|||||||
selected={option.selected}
|
selected={option.selected}
|
||||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||||
onPress={() => handleSelect(option.value)}
|
onPress={() => handleSelect(option.value)}
|
||||||
width={cardWidth}
|
width={scaledCardWidth}
|
||||||
height={cardHeight}
|
height={scaledCardHeight}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -142,21 +161,20 @@ const styles = StyleSheet.create({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
},
|
},
|
||||||
blurContainer: {
|
blurContainer: {
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: scaleSize(24),
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: scaleSize(24),
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
paddingTop: 24,
|
paddingTop: scaleSize(24),
|
||||||
paddingBottom: 50,
|
paddingBottom: scaleSize(50),
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 16,
|
marginBottom: scaleSize(16),
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
},
|
},
|
||||||
@@ -164,8 +182,8 @@ const styles = StyleSheet.create({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
paddingVertical: 20,
|
paddingVertical: scaleSize(20),
|
||||||
gap: 12,
|
gap: scaleSize(12),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,14 +22,17 @@ import {
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVTabButton, useTVFocusAnimation } from "@/components/tv";
|
import { TVTabButton, useTVFocusAnimation } from "@/components/tv";
|
||||||
import type { Track } from "@/components/video-player/controls/types";
|
import type { Track } from "@/components/video-player/controls/types";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
type SubtitleSearchResult,
|
type SubtitleSearchResult,
|
||||||
useRemoteSubtitles,
|
useRemoteSubtitles,
|
||||||
} from "@/hooks/useRemoteSubtitles";
|
} from "@/hooks/useRemoteSubtitles";
|
||||||
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
|
import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
|
||||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
type TabType = "tracks" | "download" | "settings";
|
type TabType = "tracks" | "download" | "settings";
|
||||||
@@ -72,10 +75,10 @@ const TVTrackCard = React.forwardRef<
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.trackCardText,
|
styles.trackCardText,
|
||||||
{ color: focused ? "#000" : "#fff" },
|
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(16) },
|
||||||
(focused || selected) && { fontWeight: "600" },
|
(focused || selected) && { fontWeight: "600" },
|
||||||
]}
|
]}
|
||||||
numberOfLines={2}
|
numberOfLines={3}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -83,7 +86,10 @@ const TVTrackCard = React.forwardRef<
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.trackCardSublabel,
|
styles.trackCardSublabel,
|
||||||
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
|
{
|
||||||
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
||||||
|
fontSize: scaleSize(12),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -94,7 +100,7 @@ const TVTrackCard = React.forwardRef<
|
|||||||
<View style={styles.checkmark}>
|
<View style={styles.checkmark}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='checkmark'
|
name='checkmark'
|
||||||
size={16}
|
size={scaleSize(16)}
|
||||||
color='rgba(255,255,255,0.8)'
|
color='rgba(255,255,255,0.8)'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -142,7 +148,7 @@ const LanguageCard = React.forwardRef<
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.languageCardText,
|
styles.languageCardText,
|
||||||
{ color: focused ? "#000" : "#fff" },
|
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(15) },
|
||||||
(focused || selected) && { fontWeight: "600" },
|
(focused || selected) && { fontWeight: "600" },
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@@ -152,7 +158,10 @@ const LanguageCard = React.forwardRef<
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.languageCardCode,
|
styles.languageCardCode,
|
||||||
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
|
{
|
||||||
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
||||||
|
fontSize: scaleSize(11),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{code.toUpperCase()}
|
{code.toUpperCase()}
|
||||||
@@ -161,7 +170,7 @@ const LanguageCard = React.forwardRef<
|
|||||||
<View style={styles.checkmark}>
|
<View style={styles.checkmark}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='checkmark'
|
name='checkmark'
|
||||||
size={16}
|
size={scaleSize(16)}
|
||||||
color='rgba(255,255,255,0.8)'
|
color='rgba(255,255,255,0.8)'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -219,7 +228,10 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.providerText,
|
styles.providerText,
|
||||||
{ color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)" },
|
{
|
||||||
|
color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)",
|
||||||
|
fontSize: scaleSize(11),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{result.providerName}
|
{result.providerName}
|
||||||
@@ -228,7 +240,10 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<Text
|
<Text
|
||||||
style={[styles.resultName, { color: focused ? "#000" : "#fff" }]}
|
style={[
|
||||||
|
styles.resultName,
|
||||||
|
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(14) },
|
||||||
|
]}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{result.name}
|
{result.name}
|
||||||
@@ -240,7 +255,10 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.resultMetaText,
|
styles.resultMetaText,
|
||||||
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
|
{
|
||||||
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
||||||
|
fontSize: scaleSize(12),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{result.format?.toUpperCase()}
|
{result.format?.toUpperCase()}
|
||||||
@@ -252,7 +270,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
<View style={styles.ratingContainer}>
|
<View style={styles.ratingContainer}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='star'
|
name='star'
|
||||||
size={12}
|
size={scaleSize(12)}
|
||||||
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
|
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
@@ -262,6 +280,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
color: focused
|
color: focused
|
||||||
? "rgba(0,0,0,0.6)"
|
? "rgba(0,0,0,0.6)"
|
||||||
: "rgba(255,255,255,0.5)",
|
: "rgba(255,255,255,0.5)",
|
||||||
|
fontSize: scaleSize(12),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -275,7 +294,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
<View style={styles.downloadCountContainer}>
|
<View style={styles.downloadCountContainer}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='download-outline'
|
name='download-outline'
|
||||||
size={12}
|
size={scaleSize(12)}
|
||||||
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
|
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
@@ -285,6 +304,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
color: focused
|
color: focused
|
||||||
? "rgba(0,0,0,0.6)"
|
? "rgba(0,0,0,0.6)"
|
||||||
: "rgba(255,255,255,0.5)",
|
: "rgba(255,255,255,0.5)",
|
||||||
|
fontSize: scaleSize(12),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -307,7 +327,9 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.flagText}>Hash Match</Text>
|
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||||
|
Hash Match
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{result.hearingImpaired && (
|
{result.hearingImpaired && (
|
||||||
@@ -323,7 +345,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ear-outline'
|
name='ear-outline'
|
||||||
size={12}
|
size={scaleSize(12)}
|
||||||
color={focused ? "#000" : "#fff"}
|
color={focused ? "#000" : "#fff"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -339,7 +361,9 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.flagText}>AI</Text>
|
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||||
|
AI
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -389,7 +413,7 @@ const TVStepperButton: React.FC<{
|
|||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={icon}
|
name={icon}
|
||||||
size={28}
|
size={scaleSize(28)}
|
||||||
color={focused ? "#000" : disabled ? "rgba(255,255,255,0.4)" : "#fff"}
|
color={focused ? "#000" : disabled ? "rgba(255,255,255,0.4)" : "#fff"}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -485,7 +509,7 @@ const TVAlignmentCard: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.alignmentCardText,
|
styles.alignmentCardText,
|
||||||
{ color: focused ? "#000" : "#fff" },
|
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(15) },
|
||||||
(focused || selected) && { fontWeight: "600" },
|
(focused || selected) && { fontWeight: "600" },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -495,7 +519,7 @@ const TVAlignmentCard: React.FC<{
|
|||||||
<View style={styles.alignmentCheckmark}>
|
<View style={styles.alignmentCheckmark}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='checkmark'
|
name='checkmark'
|
||||||
size={14}
|
size={scaleSize(14)}
|
||||||
color='rgba(255,255,255,0.8)'
|
color='rgba(255,255,255,0.8)'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -510,6 +534,7 @@ export default function TVSubtitleModal() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const modalState = useAtomValue(tvSubtitleModalAtom);
|
const modalState = useAtomValue(tvSubtitleModalAtom);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
||||||
@@ -604,6 +629,12 @@ export default function TVSubtitleModal() {
|
|||||||
router.back();
|
router.back();
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
// Intercept back/menu press to close the modal instead of the player
|
||||||
|
useTVBackPress(() => {
|
||||||
|
handleClose();
|
||||||
|
return true;
|
||||||
|
}, [handleClose]);
|
||||||
|
|
||||||
const handleLanguageSelect = useCallback(
|
const handleLanguageSelect = useCallback(
|
||||||
(code: string) => {
|
(code: string) => {
|
||||||
setSelectedLanguage(code);
|
setSelectedLanguage(code);
|
||||||
@@ -659,8 +690,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 +738,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]);
|
||||||
@@ -719,7 +776,7 @@ export default function TVSubtitleModal() {
|
|||||||
>
|
>
|
||||||
{/* Header with tabs */}
|
{/* Header with tabs */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>
|
<Text style={[styles.title, { fontSize: typography.heading }]}>
|
||||||
{t("item_card.subtitles.label") || "Subtitles"}
|
{t("item_card.subtitles.label") || "Subtitles"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -776,7 +833,9 @@ export default function TVSubtitleModal() {
|
|||||||
<>
|
<>
|
||||||
{/* Language Selector */}
|
{/* Language Selector */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>
|
<Text
|
||||||
|
style={[styles.sectionTitle, { fontSize: scaleSize(14) }]}
|
||||||
|
>
|
||||||
{t("player.language") || "Language"}
|
{t("player.language") || "Language"}
|
||||||
</Text>
|
</Text>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -803,7 +862,9 @@ export default function TVSubtitleModal() {
|
|||||||
|
|
||||||
{/* Results Section */}
|
{/* Results Section */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>
|
<Text
|
||||||
|
style={[styles.sectionTitle, { fontSize: scaleSize(14) }]}
|
||||||
|
>
|
||||||
{t("player.results") || "Results"}
|
{t("player.results") || "Results"}
|
||||||
{searchResults && ` (${searchResults.length})`}
|
{searchResults && ` (${searchResults.length})`}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -820,13 +881,17 @@ export default function TVSubtitleModal() {
|
|||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='alert-circle-outline'
|
name='alert-circle-outline'
|
||||||
size={32}
|
size={scaleSize(32)}
|
||||||
color='rgba(255,100,100,0.8)'
|
color='rgba(255,100,100,0.8)'
|
||||||
/>
|
/>
|
||||||
<Text style={styles.errorText}>
|
<Text
|
||||||
|
style={[styles.errorText, { fontSize: scaleSize(16) }]}
|
||||||
|
>
|
||||||
{t("player.search_failed") || "Search failed"}
|
{t("player.search_failed") || "Search failed"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.errorHint}>
|
<Text
|
||||||
|
style={[styles.errorHint, { fontSize: scaleSize(13) }]}
|
||||||
|
>
|
||||||
{!hasOpenSubtitlesApiKey
|
{!hasOpenSubtitlesApiKey
|
||||||
? t("player.no_subtitle_provider") ||
|
? t("player.no_subtitle_provider") ||
|
||||||
"No subtitle provider configured on server"
|
"No subtitle provider configured on server"
|
||||||
@@ -843,10 +908,15 @@ export default function TVSubtitleModal() {
|
|||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='document-text-outline'
|
name='document-text-outline'
|
||||||
size={32}
|
size={scaleSize(32)}
|
||||||
color='rgba(255,255,255,0.4)'
|
color='rgba(255,255,255,0.4)'
|
||||||
/>
|
/>
|
||||||
<Text style={styles.emptyText}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ fontSize: scaleSize(14) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("player.no_subtitles_found") ||
|
{t("player.no_subtitles_found") ||
|
||||||
"No subtitles found"}
|
"No subtitles found"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -881,10 +951,15 @@ export default function TVSubtitleModal() {
|
|||||||
<View style={styles.apiKeyHint}>
|
<View style={styles.apiKeyHint}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='information-circle-outline'
|
name='information-circle-outline'
|
||||||
size={16}
|
size={scaleSize(16)}
|
||||||
color='rgba(255,255,255,0.4)'
|
color='rgba(255,255,255,0.4)'
|
||||||
/>
|
/>
|
||||||
<Text style={styles.apiKeyHintText}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.apiKeyHintText,
|
||||||
|
{ fontSize: scaleSize(12) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("player.add_opensubtitles_key_hint") ||
|
{t("player.add_opensubtitles_key_hint") ||
|
||||||
"Add OpenSubtitles API key in settings for client-side fallback"}
|
"Add OpenSubtitles API key in settings for client-side fallback"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -905,8 +980,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) => {
|
||||||
@@ -916,7 +991,12 @@ export default function TVSubtitleModal() {
|
|||||||
}}
|
}}
|
||||||
hasTVPreferredFocus={true}
|
hasTVPreferredFocus={true}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.settingLabel}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.settingLabel,
|
||||||
|
{ fontSize: typography.callout },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("home.settings.subtitles.mpv_subtitle_scale") ||
|
{t("home.settings.subtitles.mpv_subtitle_scale") ||
|
||||||
"Subtitle Scale"}
|
"Subtitle Scale"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -934,7 +1014,12 @@ export default function TVSubtitleModal() {
|
|||||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.settingLabel}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.settingLabel,
|
||||||
|
{ fontSize: typography.callout },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("home.settings.subtitles.mpv_subtitle_margin_y") ||
|
{t("home.settings.subtitles.mpv_subtitle_margin_y") ||
|
||||||
"Vertical Margin"}
|
"Vertical Margin"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -958,7 +1043,12 @@ export default function TVSubtitleModal() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.settingLabel}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.settingLabel,
|
||||||
|
{ fontSize: typography.callout },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("home.settings.subtitles.mpv_subtitle_align_x") ||
|
{t("home.settings.subtitles.mpv_subtitle_align_x") ||
|
||||||
"Horizontal Align"}
|
"Horizontal Align"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -982,7 +1072,12 @@ export default function TVSubtitleModal() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.settingLabel}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.settingLabel,
|
||||||
|
{ fontSize: typography.callout },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("home.settings.subtitles.mpv_subtitle_align_y") ||
|
{t("home.settings.subtitles.mpv_subtitle_align_y") ||
|
||||||
"Vertical Align"}
|
"Vertical Align"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1007,218 +1102,201 @@ const styles = StyleSheet.create({
|
|||||||
maxHeight: "70%",
|
maxHeight: "70%",
|
||||||
},
|
},
|
||||||
blurContainer: {
|
blurContainer: {
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: scaleSize(24),
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: scaleSize(24),
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
paddingTop: 24,
|
paddingTop: scaleSize(24),
|
||||||
paddingBottom: 48,
|
paddingBottom: scaleSize(48),
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
marginBottom: 20,
|
marginBottom: scaleSize(20),
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
marginBottom: 16,
|
marginBottom: scaleSize(16),
|
||||||
},
|
},
|
||||||
tabRow: {
|
tabRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: 24,
|
gap: scaleSize(24),
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
marginBottom: 20,
|
marginBottom: scaleSize(20),
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "rgba(255,255,255,0.5)",
|
color: "rgba(255,255,255,0.5)",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
marginBottom: 12,
|
marginBottom: scaleSize(12),
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
},
|
},
|
||||||
tracksScroll: {
|
tracksScroll: {
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
tracksScrollContent: {
|
tracksScrollContent: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
paddingVertical: 8,
|
paddingVertical: scaleSize(8),
|
||||||
gap: 12,
|
gap: scaleSize(12),
|
||||||
},
|
},
|
||||||
trackCard: {
|
trackCard: {
|
||||||
width: 180,
|
width: scaleSize(180),
|
||||||
height: 80,
|
height: scaleSize(80),
|
||||||
borderRadius: 14,
|
borderRadius: scaleSize(14),
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: scaleSize(12),
|
||||||
},
|
},
|
||||||
trackCardText: {
|
trackCardText: {
|
||||||
fontSize: 16,
|
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
trackCardSublabel: {
|
trackCardSublabel: {
|
||||||
fontSize: 12,
|
marginTop: scaleSize(2),
|
||||||
marginTop: 2,
|
|
||||||
},
|
},
|
||||||
checkmark: {
|
checkmark: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 8,
|
top: scaleSize(8),
|
||||||
right: 8,
|
right: scaleSize(8),
|
||||||
},
|
},
|
||||||
languageScroll: {
|
languageScroll: {
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
languageScrollContent: {
|
languageScrollContent: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
paddingVertical: 8,
|
paddingVertical: scaleSize(8),
|
||||||
gap: 10,
|
gap: scaleSize(10),
|
||||||
},
|
},
|
||||||
languageCard: {
|
languageCard: {
|
||||||
width: 120,
|
width: scaleSize(120),
|
||||||
height: 60,
|
height: scaleSize(60),
|
||||||
borderRadius: 12,
|
borderRadius: scaleSize(12),
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: scaleSize(12),
|
||||||
},
|
},
|
||||||
languageCardText: {
|
languageCardText: {
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
languageCardCode: {
|
languageCardCode: {
|
||||||
fontSize: 11,
|
marginTop: scaleSize(2),
|
||||||
marginTop: 2,
|
|
||||||
},
|
},
|
||||||
resultsScroll: {
|
resultsScroll: {
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
resultsScrollContent: {
|
resultsScrollContent: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
paddingVertical: 8,
|
paddingVertical: scaleSize(8),
|
||||||
gap: 12,
|
gap: scaleSize(12),
|
||||||
},
|
},
|
||||||
resultCard: {
|
resultCard: {
|
||||||
width: 220,
|
width: scaleSize(220),
|
||||||
height: 130,
|
height: scaleSize(130),
|
||||||
borderRadius: 14,
|
borderRadius: scaleSize(14),
|
||||||
padding: 14,
|
padding: scaleSize(14),
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
providerBadge: {
|
providerBadge: {
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: scaleSize(8),
|
||||||
paddingVertical: 3,
|
paddingVertical: scaleSize(3),
|
||||||
borderRadius: 6,
|
borderRadius: scaleSize(6),
|
||||||
marginBottom: 8,
|
marginBottom: scaleSize(8),
|
||||||
},
|
},
|
||||||
providerText: {
|
providerText: {
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
resultName: {
|
resultName: {
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
marginBottom: 8,
|
marginBottom: scaleSize(8),
|
||||||
lineHeight: 18,
|
lineHeight: scaleSize(18),
|
||||||
},
|
},
|
||||||
resultMeta: {
|
resultMeta: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 12,
|
gap: scaleSize(12),
|
||||||
marginBottom: 8,
|
marginBottom: scaleSize(8),
|
||||||
},
|
|
||||||
resultMetaText: {
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
},
|
||||||
|
resultMetaText: {},
|
||||||
ratingContainer: {
|
ratingContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 3,
|
gap: scaleSize(3),
|
||||||
},
|
},
|
||||||
downloadCountContainer: {
|
downloadCountContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 3,
|
gap: scaleSize(3),
|
||||||
},
|
},
|
||||||
flagsContainer: {
|
flagsContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: 6,
|
gap: scaleSize(6),
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
},
|
},
|
||||||
flag: {
|
flag: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: scaleSize(6),
|
||||||
paddingVertical: 2,
|
paddingVertical: scaleSize(2),
|
||||||
borderRadius: 4,
|
borderRadius: scaleSize(4),
|
||||||
},
|
},
|
||||||
flagText: {
|
flagText: {
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
},
|
},
|
||||||
downloadingOverlay: {
|
downloadingOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFill,
|
||||||
backgroundColor: "rgba(0,0,0,0.5)",
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
borderRadius: 14,
|
borderRadius: scaleSize(14),
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
paddingVertical: 20,
|
paddingVertical: scaleSize(20),
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
errorContainer: {
|
errorContainer: {
|
||||||
paddingVertical: 40,
|
paddingVertical: scaleSize(40),
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
color: "rgba(255,100,100,0.9)",
|
color: "rgba(255,100,100,0.9)",
|
||||||
marginTop: 8,
|
marginTop: scaleSize(8),
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
errorHint: {
|
errorHint: {
|
||||||
color: "rgba(255,255,255,0.5)",
|
color: "rgba(255,255,255,0.5)",
|
||||||
marginTop: 4,
|
marginTop: scaleSize(4),
|
||||||
fontSize: 13,
|
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
emptyContainer: {
|
emptyContainer: {
|
||||||
paddingVertical: 40,
|
paddingVertical: scaleSize(40),
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
color: "rgba(255,255,255,0.5)",
|
color: "rgba(255,255,255,0.5)",
|
||||||
marginTop: 8,
|
marginTop: scaleSize(8),
|
||||||
fontSize: 14,
|
|
||||||
},
|
},
|
||||||
apiKeyHint: {
|
apiKeyHint: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: scaleSize(8),
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
paddingTop: 8,
|
paddingTop: scaleSize(8),
|
||||||
},
|
|
||||||
apiKeyHintText: {
|
|
||||||
color: "rgba(255,255,255,0.4)",
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
},
|
||||||
|
apiKeyHintText: {},
|
||||||
// Settings tab styles
|
// Settings tab styles
|
||||||
settingsScroll: {
|
settingsScroll: {
|
||||||
maxHeight: 300,
|
maxHeight: scaleSize(300),
|
||||||
},
|
},
|
||||||
settingsScrollContent: {
|
settingsScrollContent: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: scaleSize(48),
|
||||||
paddingVertical: 8,
|
paddingVertical: scaleSize(8),
|
||||||
gap: 24,
|
gap: scaleSize(24),
|
||||||
},
|
},
|
||||||
settingRow: {
|
settingRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -1226,49 +1304,47 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
},
|
},
|
||||||
settingLabel: {
|
settingLabel: {
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
},
|
},
|
||||||
sizeControlContainer: {
|
sizeControlContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 16,
|
gap: scaleSize(16),
|
||||||
},
|
},
|
||||||
stepperButton: {
|
stepperButton: {
|
||||||
width: 56,
|
width: scaleSize(56),
|
||||||
height: 56,
|
height: scaleSize(56),
|
||||||
borderRadius: 14,
|
borderRadius: scaleSize(14),
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
sizeValueContainer: {
|
sizeValueContainer: {
|
||||||
width: 80,
|
width: scaleSize(80),
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
sizeValueText: {
|
sizeValueText: {
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
|
fontSize: scaleSize(24),
|
||||||
},
|
},
|
||||||
alignmentRow: {
|
alignmentRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: 10,
|
gap: scaleSize(10),
|
||||||
},
|
},
|
||||||
alignmentCard: {
|
alignmentCard: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: scaleSize(20),
|
||||||
paddingVertical: 14,
|
paddingVertical: scaleSize(14),
|
||||||
borderRadius: 12,
|
borderRadius: scaleSize(12),
|
||||||
minWidth: 90,
|
minWidth: scaleSize(90),
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
alignmentCardText: {
|
alignmentCardText: {
|
||||||
fontSize: 15,
|
|
||||||
textTransform: "capitalize",
|
textTransform: "capitalize",
|
||||||
},
|
},
|
||||||
alignmentCheckmark: {
|
alignmentCheckmark: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 6,
|
top: scaleSize(6),
|
||||||
right: 6,
|
right: scaleSize(6),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
298
app/_layout.tsx
298
app/_layout.tsx
@@ -2,18 +2,19 @@ import "@/augmentations";
|
|||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
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,
|
||||||
@@ -24,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
|||||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
|
import { SyncPlayProvider } from "@/providers/SyncPlay";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
@@ -55,15 +57,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 +251,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 +276,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,
|
||||||
@@ -343,8 +363,8 @@ function Layout() {
|
|||||||
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
||||||
// summarized season notification for multiple episodes. Bring them to series season
|
// summarized season notification for multiple episodes. Bring them to series season
|
||||||
} else {
|
} else {
|
||||||
const seriesId = data.seriesId;
|
const seriesId = data?.seriesId;
|
||||||
const seasonIndex = data.seasonIndex;
|
const seasonIndex = data?.seasonIndex;
|
||||||
if (seasonIndex) {
|
if (seasonIndex) {
|
||||||
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
|
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
|
||||||
} else {
|
} else {
|
||||||
@@ -376,126 +396,156 @@ function Layout() {
|
|||||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||||
dehydrateOptions: {
|
dehydrateOptions: {
|
||||||
shouldDehydrateQuery: (query) => {
|
shouldDehydrateQuery: (query) => {
|
||||||
// Only persist successful queries
|
return (
|
||||||
return query.state.status === "success";
|
query.state.status === "success" && query.options.gcTime !== 0
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<ServerUrlProvider>
|
<InactivityProvider>
|
||||||
<NetworkStatusProvider>
|
<ServerUrlProvider>
|
||||||
<PlaySettingsProvider>
|
<NetworkStatusProvider>
|
||||||
<LogProvider>
|
<PlaySettingsProvider>
|
||||||
<WebSocketProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<MusicPlayerProvider>
|
<SyncPlayProvider>
|
||||||
<GlobalModalProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<MusicPlayerProvider>
|
||||||
<IntroSheetProvider>
|
<GlobalModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<IntroSheetProvider>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack.Screen
|
<SystemBars style='light' hidden={false} />
|
||||||
name='(auth)/(tabs)'
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/(tabs)'
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='(auth)/player'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/player'
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='(auth)/now-playing'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/now-playing'
|
||||||
presentation: "modal",
|
options={{
|
||||||
gestureEnabled: true,
|
headerShown: false,
|
||||||
}}
|
presentation: "modal",
|
||||||
/>
|
gestureEnabled: true,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='login'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: true,
|
name='login'
|
||||||
title: "",
|
options={{
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerShown: true,
|
||||||
}}
|
title: "",
|
||||||
/>
|
headerTransparent:
|
||||||
<Stack.Screen name='+not-found' />
|
Platform.OS === "ios",
|
||||||
<Stack.Screen
|
}}
|
||||||
name='(auth)/tv-option-modal'
|
/>
|
||||||
options={{
|
<Stack.Screen name='+not-found' />
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
presentation: "transparentModal",
|
name='(auth)/tv-option-modal'
|
||||||
animation: "fade",
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
presentation: "transparentModal",
|
||||||
<Stack.Screen
|
animation: "fade",
|
||||||
name='(auth)/tv-subtitle-modal'
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
presentation: "transparentModal",
|
name='(auth)/tv-subtitle-modal'
|
||||||
animation: "fade",
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
presentation: "transparentModal",
|
||||||
<Stack.Screen
|
animation: "fade",
|
||||||
name='(auth)/tv-request-modal'
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
presentation: "transparentModal",
|
name='(auth)/tv-request-modal'
|
||||||
animation: "fade",
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
presentation: "transparentModal",
|
||||||
<Stack.Screen
|
animation: "fade",
|
||||||
name='(auth)/tv-season-select-modal'
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
presentation: "transparentModal",
|
name='(auth)/tv-season-select-modal'
|
||||||
animation: "fade",
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
presentation: "transparentModal",
|
||||||
<Stack.Screen
|
animation: "fade",
|
||||||
name='(auth)/tv-series-season-modal'
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
presentation: "transparentModal",
|
name='(auth)/tv-series-season-modal'
|
||||||
animation: "fade",
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
presentation: "transparentModal",
|
||||||
</Stack>
|
animation: "fade",
|
||||||
<Toaster
|
}}
|
||||||
duration={4000}
|
/>
|
||||||
toastOptions={{
|
<Stack.Screen
|
||||||
style: {
|
name='tv-account-action-modal'
|
||||||
backgroundColor: "#262626",
|
options={{
|
||||||
borderColor: "#363639",
|
headerShown: false,
|
||||||
borderWidth: 1,
|
presentation: "transparentModal",
|
||||||
},
|
animation: "fade",
|
||||||
titleStyle: {
|
}}
|
||||||
color: "white",
|
/>
|
||||||
},
|
<Stack.Screen
|
||||||
}}
|
name='tv-account-select-modal'
|
||||||
closeButton
|
options={{
|
||||||
/>
|
headerShown: false,
|
||||||
{!Platform.isTV && <GlobalModal />}
|
presentation: "transparentModal",
|
||||||
</ThemeProvider>
|
animation: "fade",
|
||||||
</IntroSheetProvider>
|
}}
|
||||||
</BottomSheetModalProvider>
|
/>
|
||||||
</GlobalModalProvider>
|
<Stack.Screen
|
||||||
</MusicPlayerProvider>
|
name='(auth)/tv-user-switch-modal'
|
||||||
</DownloadProvider>
|
options={{
|
||||||
</WebSocketProvider>
|
headerShown: false,
|
||||||
</LogProvider>
|
presentation: "transparentModal",
|
||||||
</PlaySettingsProvider>
|
animation: "fade",
|
||||||
</NetworkStatusProvider>
|
}}
|
||||||
</ServerUrlProvider>
|
/>
|
||||||
|
</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>
|
||||||
|
</SyncPlayProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</NetworkStatusProvider>
|
||||||
|
</ServerUrlProvider>
|
||||||
|
</InactivityProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
33
app/topshelf/item.tsx
Normal file
33
app/topshelf/item.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
|
export default function TopShelfItemRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
const rootNavigationState = useRootNavigationState();
|
||||||
|
const { id, type } = useLocalSearchParams<{
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rootNavigationState?.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "Series") {
|
||||||
|
router.replace(`/(auth)/(tabs)/(home)/series/${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace(`/(auth)/(tabs)/(home)/items/page?id=${id}`);
|
||||||
|
}, [id, rootNavigationState?.key, router, type]);
|
||||||
|
|
||||||
|
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
|
||||||
|
}
|
||||||
32
app/topshelf/play.tsx
Normal file
32
app/topshelf/play.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
|
export default function TopShelfPlayRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
const rootNavigationState = useRootNavigationState();
|
||||||
|
const { id } = useLocalSearchParams<{
|
||||||
|
id?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rootNavigationState?.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: id,
|
||||||
|
offline: "false",
|
||||||
|
});
|
||||||
|
|
||||||
|
router.replace(`/player/direct-player?${queryParams.toString()}`);
|
||||||
|
}, [id, rootNavigationState?.key, router]);
|
||||||
|
|
||||||
|
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
assets/icons/gear.png
Normal file
BIN
assets/icons/gear.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -11,7 +11,7 @@ Number.prototype.bytesToReadable = function (decimals = 2) {
|
|||||||
const bytes = this.valueOf();
|
const bytes = this.valueOf();
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
|
||||||
const k = 1024;
|
const k = 1000;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
"!android",
|
"!android",
|
||||||
"!Streamyfin.app",
|
"!Streamyfin.app",
|
||||||
"!utils/jellyseerr",
|
"!utils/jellyseerr",
|
||||||
|
"!expo-env.d.ts",
|
||||||
|
"!modules/**/android/build",
|
||||||
"!.expo",
|
"!.expo",
|
||||||
"!docs/jellyfin-openapi-stable.json"
|
"!docs/jellyfin-openapi-stable.json"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
const getColorClasses = (
|
const getColorClasses = (
|
||||||
@@ -132,19 +133,29 @@ 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 ? scaleSize(10) : 0,
|
||||||
elevation: focused ? 12 : 0, // Android glow
|
elevation: focused ? 12 : 0, // Android glow
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl py-5 items-center justify-center
|
style={{
|
||||||
${colorClasses}
|
borderRadius: scaleSize(16),
|
||||||
${className}`}
|
paddingVertical: scaleSize(14),
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
className={`${colorClasses} ${className}`}
|
||||||
>
|
>
|
||||||
<Text className={`${textColorClass} text-xl font-bold`}>
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: scaleSize(20),
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
className={textColorClass}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,188 +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 {
|
|
||||||
GlassPosterView,
|
|
||||||
isGlassEffectAvailable,
|
|
||||||
} from "@/modules/glass-poster";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { ProgressBar } from "./common/ProgressBar";
|
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
|
||||||
|
|
||||||
export const TV_LANDSCAPE_WIDTH = 400;
|
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
|
||||||
item: BaseItemDto;
|
|
||||||
useEpisodePoster?: boolean;
|
|
||||||
size?: "small" | "normal";
|
|
||||||
showPlayButton?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|
||||||
item,
|
|
||||||
useEpisodePoster = false,
|
|
||||||
// TV version uses fixed width, size prop kept for API compatibility
|
|
||||||
size: _size = "normal",
|
|
||||||
showPlayButton = false,
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
|
|
||||||
const url = useMemo(() => {
|
|
||||||
if (!api) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (item.Type === "Episode" && useEpisodePoster) {
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
|
||||||
}
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
|
||||||
}
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
|
||||||
}
|
|
||||||
if (item.Type === "Movie") {
|
|
||||||
if (item.ImageTags?.Thumb) {
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
|
||||||
}
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
|
||||||
}
|
|
||||||
if (item.Type === "Program") {
|
|
||||||
if (item.ImageTags?.Thumb) {
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
|
||||||
}
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.ImageTags?.Thumb) {
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
|
||||||
}, [api, item, useEpisodePoster]);
|
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
|
||||||
if (item.Type === "Program") {
|
|
||||||
if (!item.StartDate || !item.EndDate) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const startDate = new Date(item.StartDate);
|
|
||||||
const endDate = new Date(item.EndDate);
|
|
||||||
const now = new Date();
|
|
||||||
const total = endDate.getTime() - startDate.getTime();
|
|
||||||
if (total <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const elapsed = now.getTime() - startDate.getTime();
|
|
||||||
return (elapsed / total) * 100;
|
|
||||||
}
|
|
||||||
return item.UserData?.PlayedPercentage || 0;
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const isWatched = item.UserData?.Played === true;
|
|
||||||
|
|
||||||
// Use glass effect on tvOS 26+
|
|
||||||
const useGlass = isGlassEffectAvailable();
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: TV_LANDSCAPE_WIDTH,
|
|
||||||
aspectRatio: 16 / 9,
|
|
||||||
borderRadius: 24,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useGlass) {
|
|
||||||
return (
|
|
||||||
<View style={{ position: "relative" }}>
|
|
||||||
<GlassPosterView
|
|
||||||
imageUrl={url}
|
|
||||||
aspectRatio={16 / 9}
|
|
||||||
cornerRadius={24}
|
|
||||||
progress={progress}
|
|
||||||
showWatchedIndicator={isWatched}
|
|
||||||
isFocused={false}
|
|
||||||
width={TV_LANDSCAPE_WIDTH}
|
|
||||||
style={{ width: TV_LANDSCAPE_WIDTH }}
|
|
||||||
/>
|
|
||||||
{showPlayButton && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-circle' size={56} color='white' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for older tvOS versions
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: TV_LANDSCAPE_WIDTH,
|
|
||||||
aspectRatio: 16 / 9,
|
|
||||||
borderRadius: 24,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
key={item.Id}
|
|
||||||
id={item.Id}
|
|
||||||
source={{
|
|
||||||
uri: url,
|
|
||||||
}}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit='cover'
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{showPlayButton && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-circle' size={56} color='white' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<WatchedIndicator item={item} />
|
|
||||||
<ProgressBar item={item} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContinueWatchingPoster;
|
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { type Href } from "expo-router";
|
import { type Href } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -73,12 +74,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,
|
||||||
@@ -195,9 +200,30 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const downloadDetailsPromises = items.map(async (item) => {
|
const downloadDetailsPromises = items.map(async (item) => {
|
||||||
|
// Ensure the snapshot we store offline carries the Chapters array.
|
||||||
|
// Page-level fetches sometimes use a fields filter that omits it; the
|
||||||
|
// offline player would then render no chapter ticks / list.
|
||||||
|
let itemForDownload = item;
|
||||||
|
if (!itemForDownload.Chapters && itemForDownload.Id) {
|
||||||
|
try {
|
||||||
|
const enriched = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: itemForDownload.Id,
|
||||||
|
userId: user.Id!,
|
||||||
|
});
|
||||||
|
if (enriched.data) {
|
||||||
|
itemForDownload = enriched.data;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { mediaSource, audioIndex, subtitleIndex } =
|
const { mediaSource, audioIndex, subtitleIndex } =
|
||||||
itemsNotDownloaded.length > 1
|
itemsNotDownloaded.length > 1
|
||||||
? getDefaultPlaySettings(item, settings!)
|
? getDefaultPlaySettings(itemForDownload, settings!)
|
||||||
: {
|
: {
|
||||||
mediaSource: selectedOptions?.mediaSource,
|
mediaSource: selectedOptions?.mediaSource,
|
||||||
audioIndex: selectedOptions?.audioIndex,
|
audioIndex: selectedOptions?.audioIndex,
|
||||||
@@ -206,7 +232,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
const downloadDetails = await getDownloadUrl({
|
const downloadDetails = await getDownloadUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item: itemForDownload,
|
||||||
userId: user.Id!,
|
userId: user.Id!,
|
||||||
mediaSource: mediaSource!,
|
mediaSource: mediaSource!,
|
||||||
audioStreamIndex: audioIndex ?? -1,
|
audioStreamIndex: audioIndex ?? -1,
|
||||||
@@ -218,7 +244,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
url: downloadDetails?.url,
|
url: downloadDetails?.url,
|
||||||
item,
|
item: itemForDownload,
|
||||||
mediaSource: downloadDetails?.mediaSource,
|
mediaSource: downloadDetails?.mediaSource,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, 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,14 +18,14 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dimensions, ScrollView, 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 { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
|
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
|
||||||
import {
|
import {
|
||||||
TVBackdrop,
|
TVBackdrop,
|
||||||
TVButton,
|
TVButton,
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
TVFavoriteButton,
|
TVFavoriteButton,
|
||||||
TVMetadataBadges,
|
TVMetadataBadges,
|
||||||
TVOptionButton,
|
TVOptionButton,
|
||||||
|
TVPlayedButton,
|
||||||
TVProgressBar,
|
TVProgressBar,
|
||||||
TVRefreshButton,
|
TVRefreshButton,
|
||||||
TVSeriesNavigation,
|
TVSeriesNavigation,
|
||||||
@@ -43,15 +45,18 @@ 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");
|
||||||
|
|
||||||
@@ -78,11 +83,15 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
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)
|
// State for first episode card ref (used for focus guide)
|
||||||
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
|
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
|
||||||
|
|
||||||
@@ -112,12 +121,22 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
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),
|
||||||
@@ -139,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
|
||||||
@@ -167,10 +224,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// State for last option button ref (used for upward focus guide from cast)
|
|
||||||
const [_lastOptionButtonRef, setLastOptionButtonRef] =
|
|
||||||
useState<View | null>(null);
|
|
||||||
|
|
||||||
// Get available audio tracks
|
// Get available audio tracks
|
||||||
const audioTracks = useMemo(() => {
|
const audioTracks = useMemo(() => {
|
||||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
@@ -192,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})`,
|
||||||
@@ -203,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(() => {
|
||||||
@@ -295,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 [];
|
||||||
@@ -322,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})`,
|
||||||
@@ -331,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 [];
|
||||||
@@ -348,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;
|
||||||
@@ -425,25 +562,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
|
||||||
}, [api, item]);
|
}, [api, item]);
|
||||||
|
|
||||||
// Determine which option button is the last one (for focus guide targeting)
|
|
||||||
const lastOptionButton = useMemo(() => {
|
|
||||||
const hasSubtitleOption =
|
|
||||||
subtitleStreams.length > 0 ||
|
|
||||||
selectedOptions?.subtitleIndex !== undefined;
|
|
||||||
const hasAudioOption = audioTracks.length > 0;
|
|
||||||
const hasMediaSourceOption = mediaSources.length > 1;
|
|
||||||
|
|
||||||
if (hasSubtitleOption) return "subtitle";
|
|
||||||
if (hasAudioOption) return "audio";
|
|
||||||
if (hasMediaSourceOption) return "mediaSource";
|
|
||||||
return "quality";
|
|
||||||
}, [
|
|
||||||
subtitleStreams.length,
|
|
||||||
selectedOptions?.subtitleIndex,
|
|
||||||
audioTracks.length,
|
|
||||||
mediaSources.length,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Navigation handlers
|
// Navigation handlers
|
||||||
const handleActorPress = useCallback(
|
const handleActorPress = useCallback(
|
||||||
(personId: string) => {
|
(personId: string) => {
|
||||||
@@ -469,7 +587,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
const handleEpisodePress = useCallback(
|
const handleEpisodePress = useCallback(
|
||||||
(episode: BaseItemDto) => {
|
(episode: BaseItemDto) => {
|
||||||
const navigation = getItemNavigation(episode, "(home)");
|
const navigation = getItemNavigation(episode, "(home)");
|
||||||
router.push(navigation as any);
|
router.replace(navigation as any);
|
||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
@@ -634,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"),
|
||||||
@@ -667,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"),
|
||||||
@@ -687,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"),
|
||||||
@@ -708,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,
|
||||||
@@ -725,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -806,26 +911,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
{t("item_card.more_from_this_season")}
|
{t("item_card.more_from_this_season")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<ScrollView
|
<TVEpisodeList
|
||||||
horizontal
|
episodes={seasonEpisodes}
|
||||||
showsHorizontalScrollIndicator={false}
|
currentEpisodeId={item.Id}
|
||||||
style={{ marginHorizontal: -80, overflow: "visible" }}
|
onEpisodePress={handleEpisodePress}
|
||||||
contentContainerStyle={{
|
onEpisodeLongPress={showItemActions}
|
||||||
paddingHorizontal: 80,
|
firstEpisodeRefSetter={setFirstEpisodeRef}
|
||||||
paddingVertical: 12,
|
horizontalPadding={insets.left + 80}
|
||||||
gap: 24,
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{seasonEpisodes.map((episode, index) => (
|
|
||||||
<TVEpisodeCard
|
|
||||||
key={episode.Id}
|
|
||||||
episode={episode}
|
|
||||||
onPress={() => handleEpisodePress(episode)}
|
|
||||||
disabled={episode.Id === item.Id}
|
|
||||||
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -845,6 +938,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
apiBasePath={api?.basePath}
|
apiBasePath={api?.basePath}
|
||||||
onActorPress={handleActorPress}
|
onActorPress={handleActorPress}
|
||||||
firstActorRefSetter={setFirstActorCardRef}
|
firstActorRefSetter={setFirstActorCardRef}
|
||||||
|
horizontalPadding={insets.left + 80}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
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 { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||||
|
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
|
||||||
|
// load and crashes the entire route tree on tvOS (expo-router requires every
|
||||||
|
// route file). Load it lazily and only off-TV; TV never renders these.
|
||||||
|
const { Button, Host, Menu } = Platform.isTV
|
||||||
|
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||||
|
: require("@expo/ui/swift-ui");
|
||||||
|
const { disabled } = Platform.isTV
|
||||||
|
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
||||||
|
: require("@expo/ui/swift-ui/modifiers");
|
||||||
|
|
||||||
// Option types
|
// Option types
|
||||||
export type RadioOption<T = any> = {
|
export type RadioOption<T = any> = {
|
||||||
type: "radio";
|
type: "radio";
|
||||||
@@ -201,6 +217,24 @@ const PlatformDropdownComponent = ({
|
|||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
|
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
||||||
|
// `matchContents` doesn't help here: it reports the native Menu's size via
|
||||||
|
// setStyleSize and overrides any explicit size. Instead we measure the
|
||||||
|
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
||||||
|
const [triggerSize, setTriggerSize] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = e.nativeEvent.layout;
|
||||||
|
setTriggerSize((prev) =>
|
||||||
|
prev && prev.width === width && prev.height === height
|
||||||
|
? prev
|
||||||
|
: { width, height },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
@@ -230,12 +264,30 @@ const PlatformDropdownComponent = ({
|
|||||||
}
|
}
|
||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
|
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||||
|
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||||
|
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||||
|
// height depends on the parent while the parent depends on the Host — a
|
||||||
|
// circular dependency that collapses to 0 for any selector nested more than
|
||||||
|
// one level deep (so only the first, shallowest dropdown stays visible).
|
||||||
|
// Giving the wrapper the measured size breaks the cycle; the Host then
|
||||||
|
// fills a concrete box.
|
||||||
return (
|
return (
|
||||||
<Host style={expoUIConfig?.hostStyle}>
|
<View style={triggerSize ?? { opacity: 0 }}>
|
||||||
<ContextMenu>
|
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||||
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
|
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||||
<ContextMenu.Items>
|
sizes to the trigger's content rather than to its parent. */}
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||||
|
pointerEvents='none'
|
||||||
|
aria-hidden
|
||||||
|
onLayout={handleMeasureTrigger}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</View>
|
||||||
|
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||||
|
<Menu label={trigger}>
|
||||||
{groups.flatMap((group, groupIndex) => {
|
{groups.flatMap((group, groupIndex) => {
|
||||||
// Check if this group has radio options
|
// Check if this group has radio options
|
||||||
const radioOptions = group.options.filter(
|
const radioOptions = group.options.filter(
|
||||||
@@ -250,27 +302,40 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
// Add Picker for radio options ONLY if there's a group title
|
// Group radio options under a submenu ONLY if there's a title
|
||||||
// Otherwise render as individual buttons
|
// Otherwise render as individual buttons
|
||||||
if (radioOptions.length > 0) {
|
if (radioOptions.length > 0) {
|
||||||
if (group.title) {
|
if (group.title) {
|
||||||
// Use Picker for grouped options
|
// Use a nested Menu as a submenu for grouped options. This
|
||||||
|
// reads as "Title: Selected" and expands to the choices on
|
||||||
|
// tap, keeping the nested look while staying a dropdown.
|
||||||
|
// (Menu opens on a single tap and nests cleanly; ContextMenu
|
||||||
|
// would require a long-press and read as a context menu.)
|
||||||
|
const selectedOption = radioOptions.find(
|
||||||
|
(opt) => opt.selected,
|
||||||
|
);
|
||||||
|
const displayTitle = selectedOption
|
||||||
|
? `${group.title}: ${selectedOption.label}`
|
||||||
|
: group.title;
|
||||||
items.push(
|
items.push(
|
||||||
<Picker
|
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
|
||||||
key={`picker-${groupIndex}`}
|
{radioOptions.map((option, optionIndex) => (
|
||||||
label={group.title}
|
<Button
|
||||||
options={radioOptions.map((opt) => opt.label)}
|
key={`radio-${groupIndex}-${optionIndex}`}
|
||||||
variant='menu'
|
label={option.label}
|
||||||
selectedIndex={radioOptions.findIndex(
|
systemImage={
|
||||||
(opt) => opt.selected,
|
option.selected ? "checkmark.circle.fill" : "circle"
|
||||||
)}
|
}
|
||||||
onOptionSelected={(event: any) => {
|
modifiers={
|
||||||
const index = event.nativeEvent.index;
|
option.disabled ? [disabled(true)] : undefined
|
||||||
const selectedOption = radioOptions[index];
|
}
|
||||||
selectedOption?.onPress();
|
onPress={() => {
|
||||||
onOptionSelect?.(selectedOption?.value);
|
option.onPress();
|
||||||
}}
|
onOptionSelect?.(option.value);
|
||||||
/>,
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Menu>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Render radio options as direct buttons
|
// Render radio options as direct buttons
|
||||||
@@ -278,17 +343,18 @@ const PlatformDropdownComponent = ({
|
|||||||
items.push(
|
items.push(
|
||||||
<Button
|
<Button
|
||||||
key={`radio-${groupIndex}-${optionIndex}`}
|
key={`radio-${groupIndex}-${optionIndex}`}
|
||||||
|
label={option.label}
|
||||||
systemImage={
|
systemImage={
|
||||||
option.selected ? "checkmark.circle.fill" : "circle"
|
option.selected ? "checkmark.circle.fill" : "circle"
|
||||||
}
|
}
|
||||||
|
modifiers={
|
||||||
|
option.disabled ? [disabled(true)] : undefined
|
||||||
|
}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
option.onPress();
|
option.onPress();
|
||||||
onOptionSelect?.(option.value);
|
onOptionSelect?.(option.value);
|
||||||
}}
|
}}
|
||||||
disabled={option.disabled}
|
/>,
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -299,17 +365,16 @@ const PlatformDropdownComponent = ({
|
|||||||
items.push(
|
items.push(
|
||||||
<Button
|
<Button
|
||||||
key={`toggle-${groupIndex}-${optionIndex}`}
|
key={`toggle-${groupIndex}-${optionIndex}`}
|
||||||
|
label={option.label}
|
||||||
systemImage={
|
systemImage={
|
||||||
option.value ? "checkmark.circle.fill" : "circle"
|
option.value ? "checkmark.circle.fill" : "circle"
|
||||||
}
|
}
|
||||||
|
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
option.onToggle();
|
option.onToggle();
|
||||||
onOptionSelect?.(option.value);
|
onOptionSelect?.(option.value);
|
||||||
}}
|
}}
|
||||||
disabled={option.disabled}
|
/>,
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,21 +383,20 @@ const PlatformDropdownComponent = ({
|
|||||||
items.push(
|
items.push(
|
||||||
<Button
|
<Button
|
||||||
key={`action-${groupIndex}-${optionIndex}`}
|
key={`action-${groupIndex}-${optionIndex}`}
|
||||||
|
label={option.label}
|
||||||
|
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
option.onPress();
|
option.onPress();
|
||||||
}}
|
}}
|
||||||
disabled={option.disabled}
|
/>,
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
})}
|
})}
|
||||||
</ContextMenu.Items>
|
</Menu>
|
||||||
</ContextMenu>
|
</Host>
|
||||||
</Host>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,15 @@ import { getDownloadedItemById } from "@/providers/Downloads/database";
|
|||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
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";
|
||||||
@@ -67,6 +68,14 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
// SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the
|
||||||
|
// server, which broadcasts a PlayQueue: NewPlaylist update to every group
|
||||||
|
// member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider
|
||||||
|
// then performs the navigation uniformly for everyone, matching
|
||||||
|
// jellyfin-web's playbackManager intercept (Controller.play).
|
||||||
|
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
|
||||||
|
useSyncPlay();
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
const effectiveColors = colors || globalColorAtom;
|
const effectiveColors = colors || globalColorAtom;
|
||||||
|
|
||||||
@@ -94,6 +103,37 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const handleNormalPlayFlow = useCallback(async () => {
|
const handleNormalPlayFlow = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
|
// SyncPlay intercept: in a group, route playback through sthe server so
|
||||||
|
// every member gets the same PlayQueue: NewPlaylist update and navigates
|
||||||
|
// together. Skips local navigation and the Chromecast prompt entirely —
|
||||||
|
// SyncPlay + Chromecast isn't a supported combination yet, same as
|
||||||
|
// jellyfin-web.
|
||||||
|
if (isSyncPlayEnabled && syncPlayController && item.Id) {
|
||||||
|
try {
|
||||||
|
// Pass the full `item` (not just the ID) so the SyncPlay controller
|
||||||
|
// can run `translateItemsForPlayback` with full context — this is
|
||||||
|
// what jellyfin-web does, and it lets us expand Series / Season /
|
||||||
|
// BoxSet into real episode/track IDs before broadcasting the queue.
|
||||||
|
// Without expansion, receivers (jellyfin-web in particular) get
|
||||||
|
// container IDs they can't play and silently fail to open the
|
||||||
|
// player.
|
||||||
|
await syncPlayController.play({
|
||||||
|
items: [item],
|
||||||
|
ids: [item.Id],
|
||||||
|
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to start group playback", error);
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("syncplay.failed_to_start", {
|
||||||
|
defaultValue: "Failed to start SyncPlay group playback",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -290,6 +330,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
goToPlayer,
|
goToPlayer,
|
||||||
isOffline,
|
isOffline,
|
||||||
t,
|
t,
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlayController,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
@@ -414,7 +456,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!item?.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData?.PlaybackPositionTicks) {
|
if (userData?.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
|
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
@@ -77,7 +78,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!item?.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData?.PlaybackPositionTicks) {
|
if (userData?.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
196
components/chapters/ChapterList.tsx
Normal file
196
components/chapters/ChapterList.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* A modal listing an item's chapters. Each row shows the chapter name and its
|
||||||
|
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
|
||||||
|
* chapter and closes the modal. Player-agnostic — the seek is injected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { memo, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import {
|
||||||
|
type ChapterEntry,
|
||||||
|
chapterStartsMs,
|
||||||
|
formatChapterTime,
|
||||||
|
sortedChapters,
|
||||||
|
} from "@/utils/chapters";
|
||||||
|
|
||||||
|
interface ChapterListProps {
|
||||||
|
visible: boolean;
|
||||||
|
chapters: ChapterInfo[] | null | undefined;
|
||||||
|
/** Current playback position in milliseconds (to highlight the row). */
|
||||||
|
currentPositionMs: number;
|
||||||
|
/** Seek the player to this millisecond position. */
|
||||||
|
onSeek: (positionMs: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 48;
|
||||||
|
|
||||||
|
function ChapterListComponent({
|
||||||
|
visible,
|
||||||
|
chapters,
|
||||||
|
currentPositionMs,
|
||||||
|
onSeek,
|
||||||
|
onClose,
|
||||||
|
}: ChapterListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||||
|
|
||||||
|
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||||
|
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
|
||||||
|
// every tick — chapters is the only input that drives the underlying array.
|
||||||
|
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
|
||||||
|
const activeIndex = useMemo(() => {
|
||||||
|
let idx = -1;
|
||||||
|
for (let i = 0; i < starts.length; i++) {
|
||||||
|
if (currentPositionMs >= starts[i]) idx = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
}, [currentPositionMs, starts]);
|
||||||
|
|
||||||
|
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
|
||||||
|
// children mounted across visible toggles, so subsequent opens never scroll.
|
||||||
|
// Trigger an imperative scroll each time the sheet becomes visible.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || activeIndex < 0 || entries.length === 0) return;
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
listRef.current?.scrollToIndex({
|
||||||
|
index: activeIndex,
|
||||||
|
animated: false,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [visible, activeIndex, entries.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType='slide'
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||||
|
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
hitSlop={10}
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.close")}
|
||||||
|
>
|
||||||
|
<Ionicons name='close' size={24} color={Colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={entries}
|
||||||
|
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
|
||||||
|
getItemLayout={(_, index) => ({
|
||||||
|
length: ROW_HEIGHT,
|
||||||
|
offset: ROW_HEIGHT * index,
|
||||||
|
index,
|
||||||
|
})}
|
||||||
|
onScrollToIndexFailed={(info) => {
|
||||||
|
// Required when getItemLayout is provided and the target index
|
||||||
|
// is outside the currently rendered window. Fallback to an
|
||||||
|
// offset-based scroll, then retry the precise scroll once a
|
||||||
|
// frame has elapsed.
|
||||||
|
listRef.current?.scrollToOffset({
|
||||||
|
offset: info.averageItemLength * info.index,
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
listRef.current?.scrollToIndex({
|
||||||
|
index: info.index,
|
||||||
|
animated: false,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
}}
|
||||||
|
renderItem={({ item, index }) => {
|
||||||
|
const positionMs = item.positionMs;
|
||||||
|
const isActive = index === activeIndex;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
onSeek(positionMs);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
styles.row,
|
||||||
|
isActive && { backgroundColor: `${Colors.primary}33` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.rowText,
|
||||||
|
{ color: isActive ? Colors.primary : Colors.text },
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.chapter.Name ||
|
||||||
|
t("chapters.chapter_number", { number: index + 1 })}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rowTime}>
|
||||||
|
{formatChapterTime(positionMs)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChapterList = memo(ChapterListComponent);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: "70%",
|
||||||
|
paddingBottom: 24,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: Colors.text,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
height: ROW_HEIGHT,
|
||||||
|
},
|
||||||
|
rowText: {
|
||||||
|
fontSize: 15,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
rowTime: {
|
||||||
|
color: Colors.icon,
|
||||||
|
fontSize: 13,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
87
components/chapters/ChapterTicks.tsx
Normal file
87
components/chapters/ChapterTicks.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Chapter tick marks drawn as an absolute overlay over a progress slider.
|
||||||
|
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
|
||||||
|
* so the slider underneath still receives touches.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
|
||||||
|
import type { ChapterMarker } from "@/utils/chapters";
|
||||||
|
|
||||||
|
interface ChapterTicksProps {
|
||||||
|
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
|
||||||
|
markers: ChapterMarker[];
|
||||||
|
/** Tick colour. */
|
||||||
|
color?: string;
|
||||||
|
/** Tick height in px — slightly less than the slider track thickness. */
|
||||||
|
height?: number;
|
||||||
|
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChapterTicksComponent({
|
||||||
|
markers,
|
||||||
|
// Semi-transparent black contrasts against both the filled progress
|
||||||
|
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
|
||||||
|
// stay visible across the whole bar as playback advances.
|
||||||
|
color = "rgba(0,0,0,0.55)",
|
||||||
|
height = 14,
|
||||||
|
width = 2,
|
||||||
|
}: ChapterTicksProps) {
|
||||||
|
// Hooks must run unconditionally — keep them before any early return.
|
||||||
|
const [sliderWidth, setSliderWidth] = useState(0);
|
||||||
|
|
||||||
|
const handleLayout = (e: LayoutChangeEvent) => {
|
||||||
|
setSliderWidth(e.nativeEvent.layout.width);
|
||||||
|
};
|
||||||
|
|
||||||
|
// One chapter (typically a single marker at 0) is not worth marking.
|
||||||
|
if (markers.length <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
pointerEvents='none'
|
||||||
|
onLayout={handleLayout}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
// Let ticks taller than this container bleed beyond its bounds.
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sliderWidth > 0 &&
|
||||||
|
markers
|
||||||
|
// Skip the leading 0ms marker — it overlaps the slider start and
|
||||||
|
// adds visual noise at an already-rendered boundary.
|
||||||
|
.filter((marker) => marker.positionMs > 0)
|
||||||
|
.map((marker, index) => {
|
||||||
|
// Align both the position AND the width onto the device's
|
||||||
|
// physical pixel grid. Without this, fractional dp values land
|
||||||
|
// at different sub-pixel fractions per tick — Android samples
|
||||||
|
// each one differently and some ticks render visibly thicker.
|
||||||
|
const centerDp = (marker.percent / 100) * sliderWidth;
|
||||||
|
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
|
||||||
|
const snappedWidth = PixelRatio.roundToNearestPixel(width);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={`${marker.positionMs}-${index}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left,
|
||||||
|
top: "50%",
|
||||||
|
marginTop: -height / 2,
|
||||||
|
height,
|
||||||
|
width: snappedWidth,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChapterTicks = memo(ChapterTicksComponent);
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type TextInputProps,
|
type TextInputProps,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
interface InputProps extends TextInputProps {
|
interface InputProps extends TextInputProps {
|
||||||
extraClassName?: string;
|
extraClassName?: string;
|
||||||
@@ -20,6 +21,9 @@ export function Input(props: InputProps) {
|
|||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
// TV-only: scales the input font with the tvTypographyScale setting.
|
||||||
|
// Not consumed by the mobile branch below.
|
||||||
|
const tvTypography = useScaledTVTypography();
|
||||||
|
|
||||||
const animateFocus = (focused: boolean) => {
|
const animateFocus = (focused: boolean) => {
|
||||||
Animated.timing(scale, {
|
Animated.timing(scale, {
|
||||||
@@ -41,8 +45,18 @@ export function Input(props: InputProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
|
// Scale the whole input (box height, padding, icon) proportionally with the
|
||||||
|
// font so the component grows/shrinks with the tvTypographyScale setting.
|
||||||
|
// Uses the `body` token (primary reading size); it resolves to 28 at Default.
|
||||||
|
const fontSize = tvTypography.body;
|
||||||
|
const factor = fontSize / 28;
|
||||||
|
const height = Math.round(56 * factor);
|
||||||
|
const paddingLeft = Math.round(24 * factor);
|
||||||
|
const iconSize = Math.round(26 * factor);
|
||||||
|
const iconMarginRight = Math.round(14 * factor);
|
||||||
|
|
||||||
const containerStyle = {
|
const containerStyle = {
|
||||||
height: 48,
|
height,
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
borderWidth: isFocused ? 1.5 : 1,
|
borderWidth: isFocused ? 1.5 : 1,
|
||||||
borderColor: isFocused
|
borderColor: isFocused
|
||||||
@@ -51,16 +65,16 @@ export function Input(props: InputProps) {
|
|||||||
overflow: "hidden" as const,
|
overflow: "hidden" as const,
|
||||||
flexDirection: "row" as const,
|
flexDirection: "row" as const,
|
||||||
alignItems: "center" as const,
|
alignItems: "center" as const,
|
||||||
paddingLeft: 16,
|
paddingLeft,
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputElement = (
|
const inputElement = (
|
||||||
<>
|
<>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='search'
|
name='search'
|
||||||
size={20}
|
size={iconSize}
|
||||||
color={isFocused ? "#999" : "#666"}
|
color={isFocused ? "#999" : "#666"}
|
||||||
style={{ marginRight: 12 }}
|
style={{ marginRight: iconMarginRight }}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -69,8 +83,8 @@ export function Input(props: InputProps) {
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 48,
|
height,
|
||||||
fontSize: 18,
|
fontSize,
|
||||||
fontWeight: "400",
|
fontWeight: "400",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
|||||||
@@ -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! },
|
||||||
|
|||||||
532
components/companion/CompanionLoginScreen.tsx
Normal file
532
components/companion/CompanionLoginScreen.tsx
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Linking,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { sendCredentialsToTV } from "@/utils/pairingService";
|
||||||
|
|
||||||
|
type ScreenState =
|
||||||
|
| "scanning"
|
||||||
|
| "no-permission"
|
||||||
|
| "confirm"
|
||||||
|
| "form"
|
||||||
|
| "sending"
|
||||||
|
| "success"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
interface ParsedPairingCode {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpoCameraModule = typeof import("expo-camera");
|
||||||
|
|
||||||
|
const ExpoCamera: ExpoCameraModule | null = Platform.isTV
|
||||||
|
? null
|
||||||
|
: require("expo-camera");
|
||||||
|
|
||||||
|
export const CompanionLoginScreen: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const [screenState, setScreenState] = useState<ScreenState>(
|
||||||
|
Platform.isTV ? "form" : "scanning",
|
||||||
|
);
|
||||||
|
const [pairingCode, setPairingCode] = useState<string>("");
|
||||||
|
const [serverUrl, setServerUrl] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Pre-fill server URL and username from current session
|
||||||
|
useEffect(() => {
|
||||||
|
if (api?.basePath) {
|
||||||
|
setServerUrl(api.basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.Name) {
|
||||||
|
setUsername(user.Name);
|
||||||
|
}
|
||||||
|
}, [api?.basePath, user?.Name]);
|
||||||
|
|
||||||
|
// Request camera permission
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ExpoCamera) return;
|
||||||
|
|
||||||
|
ExpoCamera.Camera.getCameraPermissionsAsync().then((response) => {
|
||||||
|
if (!response.granted) {
|
||||||
|
ExpoCamera.Camera.requestCameraPermissionsAsync().then((result) => {
|
||||||
|
if (!result.granted) {
|
||||||
|
setScreenState("no-permission");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validateAndParseQR = useCallback(
|
||||||
|
(data: string): ParsedPairingCode | null => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.action === "streamyfin-pair" &&
|
||||||
|
typeof parsed.code === "string" &&
|
||||||
|
parsed.code.length > 0
|
||||||
|
) {
|
||||||
|
return { code: parsed.code };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBarCodeScanned = useCallback(
|
||||||
|
({ data }: { data: string }) => {
|
||||||
|
if (screenState !== "scanning") return;
|
||||||
|
|
||||||
|
const parsed = validateAndParseQR(data);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
setErrorMessage(t("companion_login.error_invalid_qr"));
|
||||||
|
setScreenState("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPairingCode(parsed.code);
|
||||||
|
|
||||||
|
// If user is logged in, show confirmation screen (still needs password)
|
||||||
|
// Otherwise, go straight to the full form
|
||||||
|
if (user?.Name && api?.basePath) {
|
||||||
|
setScreenState("confirm");
|
||||||
|
} else {
|
||||||
|
setScreenState("form");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[screenState, validateAndParseQR, t, user?.Name, api?.basePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSendCredentials = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
!serverUrl.trim() ||
|
||||||
|
!username.trim() ||
|
||||||
|
!password.trim() ||
|
||||||
|
!pairingCode
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScreenState("sending");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendCredentialsToTV(
|
||||||
|
pairingCode,
|
||||||
|
serverUrl.trim(),
|
||||||
|
username.trim(),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
|
||||||
|
setScreenState("success");
|
||||||
|
} catch {
|
||||||
|
setErrorMessage(t("companion_login.error_generic"));
|
||||||
|
setScreenState("error");
|
||||||
|
}
|
||||||
|
}, [pairingCode, serverUrl, username, password, t]);
|
||||||
|
|
||||||
|
const handleScanAgain = useCallback(() => {
|
||||||
|
setPairingCode("");
|
||||||
|
setErrorMessage(null);
|
||||||
|
setPassword("");
|
||||||
|
setScreenState("scanning");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDone = useCallback(() => {
|
||||||
|
router.back();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleUseDifferentUser = useCallback(() => {
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setScreenState("form");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEnterCodeManually = useCallback(() => {
|
||||||
|
setScreenState("form");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (screenState === "no-permission") {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<View className='flex-1 items-center justify-center p-8'>
|
||||||
|
<Text className='mb-3 text-center text-3xl font-bold text-white'>
|
||||||
|
{t("companion_login.error_permission_denied")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{Platform.OS === "ios" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => Linking.openSettings()}
|
||||||
|
className='mt-4 rounded-lg bg-purple-600 px-6 py-3'
|
||||||
|
>
|
||||||
|
<Text className='text-base font-semibold text-white'>
|
||||||
|
{t("companion_login.open_settings")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={handleDone}
|
||||||
|
color='white'
|
||||||
|
className='mt-4'
|
||||||
|
textClassName='flex-1 text-center'
|
||||||
|
>
|
||||||
|
{t("companion_login.done")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenState === "success") {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<View className='flex-1 items-center justify-center p-8'>
|
||||||
|
<Text className='mb-3 text-center text-3xl font-bold text-white'>
|
||||||
|
{t("companion_login.success_title")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className='mb-8 text-center text-base text-gray-400'>
|
||||||
|
{t("companion_login.pairing_tv_connecting")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={handleDone}
|
||||||
|
color='purple'
|
||||||
|
textClassName='flex-1 text-center'
|
||||||
|
>
|
||||||
|
{t("companion_login.done")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenState === "error") {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<View className='flex-1 items-center justify-center p-8'>
|
||||||
|
<Text className='mb-3 text-center text-3xl font-bold text-white'>
|
||||||
|
{t("companion_login.error_title")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className='mb-8 text-center text-base text-gray-400'>
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className='mt-4 flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
onPress={handleScanAgain}
|
||||||
|
color='purple'
|
||||||
|
textClassName='flex-1 text-center'
|
||||||
|
>
|
||||||
|
{t("companion_login.scan_again")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={handleDone}
|
||||||
|
color='white'
|
||||||
|
textClassName='flex-1 text-center'
|
||||||
|
>
|
||||||
|
{t("companion_login.done")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenState === "sending") {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<View className='flex-1 items-center justify-center p-8'>
|
||||||
|
<Text className='text-xl text-white'>
|
||||||
|
{t("companion_login.authorizing")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenState === "confirm") {
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className='flex-1 bg-black'
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
>
|
||||||
|
<Text className='mb-2 text-center text-2xl font-bold text-white'>
|
||||||
|
{t("companion_login.login_as", { username })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className='mb-8 text-center text-base text-gray-400'>
|
||||||
|
{t("companion_login.on_server", {
|
||||||
|
server: serverUrl.replace(/^https?:\/\//, ""),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className='mb-6 items-center'>
|
||||||
|
<Text className='mb-1 text-sm text-gray-400'>
|
||||||
|
{t("companion_login.pairing_code_label")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className='mb-8 text-center text-4xl font-bold tracking-[6px] text-white'>
|
||||||
|
{pairingCode}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mb-5'>
|
||||||
|
<Text className='mb-2 text-sm text-gray-400'>
|
||||||
|
{t("login.password_placeholder")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder={t("login.password_placeholder")}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry
|
||||||
|
returnKeyType='done'
|
||||||
|
onSubmitEditing={handleSendCredentials}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mt-2'>
|
||||||
|
<Button
|
||||||
|
onPress={handleSendCredentials}
|
||||||
|
disabled={!password.trim()}
|
||||||
|
color='purple'
|
||||||
|
textClassName='flex-1 text-center'
|
||||||
|
>
|
||||||
|
{t("companion_login.authorize_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mt-6 items-center'>
|
||||||
|
<TouchableOpacity onPress={handleUseDifferentUser} className='py-2'>
|
||||||
|
<Text className='text-base text-gray-400 underline'>
|
||||||
|
{t("companion_login.use_different_user")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={handleScanAgain} className='py-2'>
|
||||||
|
<Text className='text-sm text-gray-500 underline'>
|
||||||
|
{t("companion_login.scan_again")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenState === "form") {
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className='flex-1 bg-black'
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 14,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
>
|
||||||
|
<Text className='mb-2 text-2xl font-bold text-white'>
|
||||||
|
{t("companion_login.pairing_enter_credentials")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className='mb-5'>
|
||||||
|
<Text className='mb-2 text-sm text-gray-400'>
|
||||||
|
{t("companion_login.pairing_code_label")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-center text-2xl font-bold tracking-[6px] text-white'
|
||||||
|
value={pairingCode}
|
||||||
|
onChangeText={setPairingCode}
|
||||||
|
placeholder={t("companion_login.pairing_code_label")}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
autoCapitalize='characters'
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType='next'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mb-5'>
|
||||||
|
<Text className='mb-2 text-sm text-gray-400'>
|
||||||
|
{t("companion_login.server")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
|
||||||
|
value={serverUrl}
|
||||||
|
onChangeText={setServerUrl}
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='next'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mb-5'>
|
||||||
|
<Text className='mb-2 text-sm text-gray-400'>
|
||||||
|
{t("login.username_placeholder")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
placeholder={t("login.username_placeholder")}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType='next'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mb-5'>
|
||||||
|
<Text className='mb-2 text-sm text-gray-400'>
|
||||||
|
{t("login.password_placeholder")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder={t("login.password_placeholder")}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry
|
||||||
|
returnKeyType='done'
|
||||||
|
onSubmitEditing={handleSendCredentials}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex-row justify-center gap-3'>
|
||||||
|
<Button
|
||||||
|
onPress={handleScanAgain}
|
||||||
|
color='black'
|
||||||
|
className='w-40 border border-neutral-700 bg-neutral-800'
|
||||||
|
textClassName='flex-1 text-center'
|
||||||
|
>
|
||||||
|
{t("companion_login.scan_again")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={handleSendCredentials}
|
||||||
|
disabled={
|
||||||
|
!serverUrl.trim() ||
|
||||||
|
!username.trim() ||
|
||||||
|
!password.trim() ||
|
||||||
|
!pairingCode.trim()
|
||||||
|
}
|
||||||
|
className='w-40'
|
||||||
|
color='purple'
|
||||||
|
textClassName='flex-1 text-center'
|
||||||
|
>
|
||||||
|
{t("companion_login.authorize_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CameraView = ExpoCamera?.CameraView;
|
||||||
|
|
||||||
|
if (!CameraView) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black items-center justify-center p-8'>
|
||||||
|
<Button
|
||||||
|
onPress={handleEnterCodeManually}
|
||||||
|
color='purple'
|
||||||
|
textClassName='flex-1 text-center'
|
||||||
|
>
|
||||||
|
{t("companion_login.enter_code_manually")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black items-center justify-center'>
|
||||||
|
{/* Camera full screen */}
|
||||||
|
<CameraView
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
onBarcodeScanned={handleBarCodeScanned}
|
||||||
|
barcodeScannerSettings={{
|
||||||
|
barcodeTypes: ["qr"],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dark overlay */}
|
||||||
|
<View className='absolute inset-0 bg-black/60' />
|
||||||
|
|
||||||
|
{/* Center scan area */}
|
||||||
|
<View className='items-center'>
|
||||||
|
<View className='h-[250px] w-[250px] rounded-2xl border-2 border-white/80' />
|
||||||
|
|
||||||
|
<Text className='mt-6 text-center text-base text-white'>
|
||||||
|
{t("companion_login.align_qr")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleEnterCodeManually}
|
||||||
|
className='mt-4 px-5 py-2'
|
||||||
|
>
|
||||||
|
<Text className='text-sm text-gray-400 underline'>
|
||||||
|
{t("companion_login.enter_code_manually")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}, [process?.progress]);
|
}, [process?.progress]);
|
||||||
|
|
||||||
// Return null after all hooks have been called
|
// Return null after all hooks have been called
|
||||||
if (!process || !process.item || !process.item.Id) {
|
if (!process?.item?.Id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,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 }}>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user