Compare commits
305 Commits
0.51.0
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3438e78cab | ||
|
|
67bca1f989 | ||
|
|
c35e97f388 | ||
|
|
bc575c26c1 | ||
|
|
ab526f2c6b | ||
|
|
7d0b3be8c2 | ||
|
|
a384b34402 | ||
|
|
07f535a6e4 | ||
|
|
2bcf52209e | ||
|
|
fb7cee7718 | ||
|
|
2775075187 | ||
|
|
4962f2161f | ||
|
|
25ec9c4348 | ||
|
|
d17414bc93 | ||
|
|
fea3e1449a | ||
|
|
ad1d9b5888 | ||
|
|
3d406314a4 | ||
|
|
e6598f0944 | ||
|
|
f549e8eaed | ||
|
|
dab1c10a03 | ||
|
|
7e2962e539 | ||
|
|
81cf672eb7 | ||
|
|
591d89c19f | ||
|
|
44b7434cdd | ||
|
|
717186e13e | ||
|
|
4afab8d94a | ||
|
|
4601ae20b6 | ||
|
|
1ec887c29e | ||
|
|
85a74a9a6a | ||
|
|
6e85c8d54a | ||
|
|
bf518b4834 | ||
|
|
d78ac2963f | ||
|
|
2818c17e97 | ||
|
|
b87e7a159f | ||
|
|
af2cac0e86 | ||
|
|
28e3060ace | ||
|
|
3814237ac6 | ||
|
|
aed3a8f493 | ||
|
|
0cd74519d4 | ||
|
|
8ecb7c205b | ||
|
|
3827350ffd | ||
|
|
53902aebab | ||
|
|
bf3a37c61c | ||
|
|
2c0a9b6cd9 | ||
|
|
80136f1800 | ||
|
|
01298c9b6d | ||
|
|
4bea01c963 | ||
|
|
94ac458f52 | ||
|
|
409629bb4a | ||
|
|
2ff9625903 | ||
|
|
8dcd4c40f9 | ||
|
|
74114893e5 | ||
|
|
268a6d96de | ||
|
|
2780b902e9 | ||
|
|
6033958158 | ||
|
|
9763c26046 | ||
|
|
05a2627c94 | ||
|
|
62a099e82f | ||
|
|
43ca6e9148 | ||
|
|
1cbb46f0ca | ||
|
|
21f2ceefc3 | ||
|
|
9d6a9decc9 | ||
|
|
246e0af0f6 | ||
|
|
a0dd752d8f | ||
|
|
c5eb7b0c96 | ||
|
|
55c74ab383 | ||
|
|
7fe24369c0 | ||
|
|
111397a306 | ||
|
|
b79b343ce3 | ||
|
|
c029228138 | ||
|
|
d51cf47eb4 | ||
|
|
bbd7854287 | ||
|
|
44caf4b1ff | ||
|
|
92c70fadd1 | ||
|
|
f637367b82 | ||
|
|
715764cef8 | ||
|
|
36d6686258 | ||
|
|
dca7cc99f2 | ||
|
|
875a017e8c | ||
|
|
0c6c20f563 | ||
|
|
2c9906377d | ||
|
|
d5f7a18fe5 | ||
|
|
4606b9718e | ||
|
|
c2d61654b0 | ||
|
|
2c6938c739 | ||
|
|
1f454c0f12 | ||
|
|
c215fda973 | ||
|
|
a852e2e769 | ||
|
|
29873e08d7 | ||
|
|
5ce5cc2d99 | ||
|
|
ae5a71ff29 | ||
|
|
0e3e8b8016 | ||
|
|
d07a521f60 | ||
|
|
566ff485fb | ||
|
|
3a4042efd5 | ||
|
|
fb9b4b6f2d | ||
|
|
1b80db678e | ||
|
|
093fcc6187 | ||
|
|
26e8489384 | ||
|
|
02a65059b9 | ||
|
|
be2fd53f31 | ||
|
|
be92b5d75e | ||
|
|
3f882ecade | ||
|
|
4b7007386f | ||
|
|
d2790f4997 | ||
|
|
096670a0c3 | ||
|
|
aa6b441dd1 | ||
|
|
d8512897ad | ||
|
|
11b6f16cd3 | ||
|
|
506d8b14dc | ||
|
|
a8acdf4299 | ||
|
|
2a9f4c2885 | ||
|
|
0353a718f3 | ||
|
|
e3b4952c60 | ||
|
|
5f44540b6f | ||
|
|
4705c9f4f9 | ||
|
|
2b36d4bc76 | ||
|
|
f4445c4152 | ||
|
|
16a236393d | ||
|
|
eeb4ef3008 | ||
|
|
358e00d8b7 | ||
|
|
a173db9180 | ||
|
|
a8c07a31d3 | ||
|
|
493df28b8d | ||
|
|
749473c1e8 | ||
|
|
f8d1fad6d5 | ||
|
|
81af2afef8 | ||
|
|
9ef79ef364 | ||
|
|
c7077bbcfe | ||
|
|
c0f25a2b8b | ||
|
|
83babc2687 | ||
|
|
f9a3a1f9f6 | ||
|
|
0f076d197f | ||
|
|
d28b5411d5 | ||
|
|
1da49d29d7 | ||
|
|
7af4b913d7 | ||
|
|
a667723d93 | ||
|
|
94bfa26041 | ||
|
|
d545ca3584 | ||
|
|
773701d0c1 | ||
|
|
a3f7d0c275 | ||
|
|
5b7ded08cc | ||
|
|
60dd00ad7e | ||
|
|
ec653cae15 | ||
|
|
18bc45ea0a | ||
|
|
ebb33854d7 | ||
|
|
9efa2bbaa2 | ||
|
|
c515d037cf | ||
|
|
ee3a288fa0 | ||
|
|
c0171aa656 | ||
|
|
41d3e61261 | ||
|
|
8f74c3edc7 | ||
|
|
56ffec3173 | ||
|
|
9509a427c8 | ||
|
|
cfcfb486bf | ||
|
|
407ea69425 | ||
|
|
e1e91ea1a6 | ||
|
|
e7ea8a2c3b | ||
|
|
9f1791ce93 | ||
|
|
38cb7068ef | ||
|
|
cc154f0c16 | ||
|
|
866aa44277 | ||
|
|
ff3f88c53b | ||
|
|
3fd76b1356 | ||
|
|
a86df6c46b | ||
|
|
bdd284b9a6 | ||
|
|
fff7d4459f | ||
|
|
b85549016d | ||
|
|
6c35608404 | ||
|
|
74e3465a84 | ||
|
|
be32d933bb | ||
|
|
db89295d9b | ||
|
|
8d90fe3a8b | ||
|
|
4880392197 | ||
|
|
e10a99cc48 | ||
|
|
55b897883b | ||
|
|
fe26a74451 | ||
|
|
4cdbab7d19 | ||
|
|
3e695def23 | ||
|
|
15e4c18d54 | ||
|
|
87169480a1 | ||
|
|
bd9467b09e | ||
|
|
6216e7fdb7 | ||
|
|
6d2e897c9f | ||
|
|
ad5148daad | ||
|
|
c1e12d5898 | ||
|
|
7416c8297a | ||
|
|
9727bec7ab | ||
|
|
6ba767a848 | ||
|
|
4ad103acb6 | ||
|
|
36304ad58e | ||
|
|
baeb83581e | ||
|
|
05b7a4c50d | ||
|
|
28b67f3ad6 | ||
|
|
51cd195bfe | ||
|
|
0184e266a0 | ||
|
|
ae658aa5b0 | ||
|
|
81f79a54af | ||
|
|
ca1b640a61 | ||
|
|
e771949c95 | ||
|
|
78bfa68a17 | ||
|
|
ac59615d79 | ||
|
|
4dd80cd8f5 | ||
|
|
db9f02b225 | ||
|
|
7a0bbb1084 | ||
|
|
05925530c0 | ||
|
|
625a292e26 | ||
|
|
1acd3102ea | ||
|
|
543881dc41 | ||
|
|
5d93483dc2 | ||
|
|
d54a29020a | ||
|
|
1d04e39b85 | ||
|
|
ecc62259fc | ||
|
|
ffd96e05fe | ||
|
|
8541ba02d4 | ||
|
|
6c955d8a2a | ||
|
|
b0bb6c6c9a | ||
|
|
82abc291d4 | ||
|
|
3da4b42ca3 | ||
|
|
16940075b2 | ||
|
|
a3bbb1bc3a | ||
|
|
1874c116a6 | ||
|
|
7a0f70778d | ||
|
|
6957c4fd64 | ||
|
|
1c0ed82deb | ||
|
|
ad54823f96 | ||
|
|
cfa638afc6 | ||
|
|
467bea7192 | ||
|
|
ac9ac5d423 | ||
|
|
62b45121e5 | ||
|
|
0e238ad10e | ||
|
|
ce793e3469 | ||
|
|
beba4853b9 | ||
|
|
d1b15a9dde | ||
|
|
12847894df | ||
|
|
773eae8cb9 | ||
|
|
781c309332 | ||
|
|
8a7f5fd4c5 | ||
|
|
838c8f48a3 | ||
|
|
399eb0a351 | ||
|
|
f1575ca48b | ||
|
|
df2f44e086 | ||
|
|
9d566d697d | ||
|
|
35f7257a6d | ||
|
|
d4ab62bdd6 | ||
|
|
ca7591ad21 | ||
|
|
7514bc2c2c | ||
|
|
241f8c949a | ||
|
|
f369738f7b | ||
|
|
ac6288d9a4 | ||
|
|
91128944c6 | ||
|
|
81449963fa | ||
|
|
8569cd390b | ||
|
|
3959aa2f72 | ||
|
|
097001b092 | ||
|
|
d1387ec725 | ||
|
|
51ecde1565 | ||
|
|
0a0da687d5 | ||
|
|
0a3cc87b2a | ||
|
|
77596bd788 | ||
|
|
630e7a1a36 | ||
|
|
04bc412bce | ||
|
|
99aa6bd342 | ||
|
|
a3751c4801 | ||
|
|
e8ddd1cad7 | ||
|
|
be8651357b | ||
|
|
588c8ffeb5 | ||
|
|
3e01310466 | ||
|
|
f3417b5a75 | ||
|
|
f97d7d78c1 | ||
|
|
eaa519fdda | ||
|
|
d84ff82590 | ||
|
|
ea271b8cdd | ||
|
|
b6270f1157 | ||
|
|
fe6299c22b | ||
|
|
88722b3e17 | ||
|
|
1a4159ffee | ||
|
|
bd13791a83 | ||
|
|
968c3e35d5 | ||
|
|
b822e315a8 | ||
|
|
f862a2424e | ||
|
|
77ee4aca27 | ||
|
|
d4ea8debfa | ||
|
|
e1dd410f73 | ||
|
|
896c7460df | ||
|
|
3453fd22b8 | ||
|
|
1c3369c61f | ||
|
|
99c9caf03c | ||
|
|
055357de60 | ||
|
|
a24e254a9e | ||
|
|
4bdb8003bb | ||
|
|
9825ccce6e | ||
|
|
9bfac2a4d9 | ||
|
|
478052599f | ||
|
|
37b0b10098 | ||
|
|
9ca852bb7e | ||
|
|
a37a8753c2 | ||
|
|
5eae6e6cd0 | ||
|
|
e0c408452d | ||
|
|
4c8dfa0e2f | ||
|
|
6c95962e12 | ||
|
|
cbbac3c25c | ||
|
|
964d53cc79 | ||
|
|
a26980ddab | ||
|
|
090ed98233 |
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]
|
||||||
|
```
|
||||||
70
.claude/commands/reflect.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
description: Reflect on this session to extract and store learned facts about the codebase
|
||||||
|
---
|
||||||
|
|
||||||
|
Analyze the current conversation to extract useful facts that should be remembered for future sessions. Focus on:
|
||||||
|
|
||||||
|
1. **Corrections**: Things the user corrected you about
|
||||||
|
2. **Clarifications**: Misunderstandings about how the codebase works
|
||||||
|
3. **Patterns**: Important conventions or patterns you learned
|
||||||
|
4. **Gotchas**: Surprising behaviors or edge cases discovered
|
||||||
|
5. **Locations**: Files or code that was hard to find
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. Read the Learned Facts Index section in `CLAUDE.md` and scan existing files in `.claude/learned-facts/` to understand what's already recorded
|
||||||
|
2. Review this conversation for learnings worth preserving
|
||||||
|
3. For each new fact:
|
||||||
|
- Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below
|
||||||
|
- Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md`
|
||||||
|
4. Skip facts that duplicate existing entries
|
||||||
|
5. If a new category is needed, add it to the index in `CLAUDE.md`
|
||||||
|
|
||||||
|
## Fact File Template
|
||||||
|
|
||||||
|
Create each file at `.claude/learned-facts/[kebab-case-name].md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Title]
|
||||||
|
|
||||||
|
**Date**: YYYY-MM-DD
|
||||||
|
**Category**: navigation | tv | native-modules | state-management | ui
|
||||||
|
**Key files**: `relevant/paths.ts`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
[Full description of the fact, including context for why it matters]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Index Entry Format
|
||||||
|
|
||||||
|
Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`:
|
||||||
|
|
||||||
|
```
|
||||||
|
- `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).
|
||||||
48
.claude/learned-facts.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Learned Facts (DEPRECATED)
|
||||||
|
|
||||||
|
> **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 previously contained facts about the codebase learned from past sessions.
|
||||||
|
|
||||||
|
## Facts
|
||||||
|
|
||||||
|
<!-- New facts will be appended below this line -->
|
||||||
|
|
||||||
|
- **Native bottom tabs + useRouter conflict**: 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. _(2025-01-09)_
|
||||||
|
|
||||||
|
- **IntroSheet rendering location**: 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. _(2025-01-09)_
|
||||||
|
|
||||||
|
- **Intro modal trigger location**: 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. _(2025-01-09)_
|
||||||
|
|
||||||
|
- **Tab folder naming**: The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions. _(2025-01-09)_
|
||||||
|
|
||||||
|
- **macOS header buttons fix**: 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. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **Header button locations**: 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`. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **useNetworkAwareQueryClient limitations**: 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`. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_
|
||||||
|
|
||||||
|
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
|
||||||
|
|
||||||
|
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
|
||||||
|
|
||||||
|
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
|
||||||
|
|
||||||
|
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
|
||||||
9
.claude/learned-facts/header-button-locations.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Header Button Locations
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: ui
|
||||||
|
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`, `components/common/HeaderBackButton.tsx`, `components/Chromecast.tsx`, `components/RoundButton.tsx`, `components/home/Home.tsx`, `app/(auth)/(tabs)/(home)/downloads/index.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`.
|
||||||
9
.claude/learned-facts/intro-modal-trigger-location.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Intro Modal Trigger Location
|
||||||
|
|
||||||
|
**Date**: 2025-01-09
|
||||||
|
**Category**: navigation
|
||||||
|
**Key files**: `components/home/Home.tsx`, `app/(auth)/(tabs)/_layout.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation.
|
||||||
9
.claude/learned-facts/introsheet-rendering-location.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# IntroSheet Rendering Location
|
||||||
|
|
||||||
|
**Date**: 2025-01-09
|
||||||
|
**Category**: navigation
|
||||||
|
**Key files**: `providers/IntroSheetProvider`, `components/IntroSheet`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs.
|
||||||
9
.claude/learned-facts/macos-header-buttons-fix.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# macOS Header Buttons Fix
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: ui
|
||||||
|
**Key files**: `components/common/HeaderBackButton.tsx`, `app/(auth)/(tabs)/(home)/_layout.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app.
|
||||||
9
.claude/learned-facts/mark-as-played-flow.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Mark as Played Flow
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: state-management
|
||||||
|
**Key files**: `components/PlayedStatus.tsx`, `hooks/useMarkAsPlayed.ts`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# MPV avfoundation-composite-osd Ordering
|
||||||
|
|
||||||
|
**Date**: 2026-01-22
|
||||||
|
**Category**: native-modules
|
||||||
|
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support).
|
||||||
9
.claude/learned-facts/mpv-tvos-player-exit-freeze.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# MPV tvOS Player Exit Freeze
|
||||||
|
|
||||||
|
**Date**: 2026-01-22
|
||||||
|
**Category**: native-modules
|
||||||
|
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Native Bottom Tabs + useRouter Conflict
|
||||||
|
|
||||||
|
**Date**: 2025-01-09
|
||||||
|
**Category**: navigation
|
||||||
|
**Key files**: `providers/`, `app/_layout.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead.
|
||||||
9
.claude/learned-facts/native-swiftui-view-sizing.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Native SwiftUI View Sizing
|
||||||
|
|
||||||
|
**Date**: 2026-01-25
|
||||||
|
**Category**: native-modules
|
||||||
|
**Key files**: `modules/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Platform-Specific File Suffix (.tv.tsx) Does NOT Work
|
||||||
|
|
||||||
|
**Date**: 2026-01-26
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `app/`, `components/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Stack Screen Header Configuration
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: ui
|
||||||
|
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Streamystats Components Location
|
||||||
|
|
||||||
|
**Date**: 2026-01-25
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `components/home/StreamystatsRecommendations.tv.tsx`, `components/home/StreamystatsPromotedWatchlists.tv.tsx`, `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`.
|
||||||
9
.claude/learned-facts/tab-folder-naming.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Tab Folder Naming
|
||||||
|
|
||||||
|
**Date**: 2025-01-09
|
||||||
|
**Category**: navigation
|
||||||
|
**Key files**: `app/(auth)/(tabs)/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Thread-Safe State for Stop Flags
|
||||||
|
|
||||||
|
**Date**: 2026-01-22
|
||||||
|
**Category**: native-modules
|
||||||
|
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time.
|
||||||
9
.claude/learned-facts/tv-grid-layout-pattern.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# TV Grid Layout Pattern
|
||||||
|
|
||||||
|
**Date**: 2026-01-25
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `components/tv/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing.
|
||||||
9
.claude/learned-facts/tv-horizontal-padding-standard.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# TV Horizontal Padding Standard
|
||||||
|
|
||||||
|
**Date**: 2026-01-25
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `components/tv/`, `app/(auth)/(tabs)/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# TV Modals Must Use Navigation Pattern
|
||||||
|
|
||||||
|
**Date**: 2026-01-24
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `hooks/useTVOptionModal.ts`, `app/(auth)/tv-option-modal.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# useNetworkAwareQueryClient Limitations
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: state-management
|
||||||
|
**Key files**: `hooks/useNetworkAwareQueryClient.ts`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.
|
||||||
12
.github/crowdin.yml
vendored
@@ -1,12 +0,0 @@
|
|||||||
"project_id_env": "CROWDIN_PROJECT_ID"
|
|
||||||
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
|
|
||||||
"base_path": "."
|
|
||||||
|
|
||||||
"preserve_hierarchy": true
|
|
||||||
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"source": "translations/en.json",
|
|
||||||
"translation": "translations/%two_letters_code%.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
121
.github/workflows/build-apps.yml
vendored
@@ -20,8 +20,20 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: 🗑️ Free Disk Space
|
||||||
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
|
with:
|
||||||
|
tool-cache: false
|
||||||
|
mandb: true
|
||||||
|
android: false
|
||||||
|
dotnet: true
|
||||||
|
haskell: true
|
||||||
|
large-packages: true
|
||||||
|
docker-images: true
|
||||||
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -29,12 +41,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -48,7 +60,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -61,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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -76,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -91,8 +103,20 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: 🗑️ Free Disk Space
|
||||||
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
|
with:
|
||||||
|
tool-cache: false
|
||||||
|
mandb: true
|
||||||
|
android: false
|
||||||
|
dotnet: true
|
||||||
|
haskell: true
|
||||||
|
large-packages: true
|
||||||
|
docker-images: true
|
||||||
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -100,12 +124,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -119,7 +143,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -132,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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -147,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -163,7 +187,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -171,12 +195,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -194,7 +218,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.0.1"
|
xcode-version: "26.2"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@main
|
||||||
@@ -203,9 +227,6 @@ jobs:
|
|||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
eas-cache: true
|
eas-cache: true
|
||||||
|
|
||||||
- name: ⚙️ Ensure iOS SDKs installed
|
|
||||||
run: xcodebuild -downloadPlatform iOS
|
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
@@ -215,12 +236,69 @@ 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||||
path: build-*.ipa
|
path: build-*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
build-ios-phone-unsigned:
|
||||||
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
|
runs-on: macos-26
|
||||||
|
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout code
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
|
with:
|
||||||
|
xcode-version: "26.2"
|
||||||
|
|
||||||
|
- name: 🚀 Build iOS app
|
||||||
|
env:
|
||||||
|
EXPO_TV: 0
|
||||||
|
run: bun run ios:unsigned-build ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
|
||||||
|
|
||||||
|
- name: 📅 Set date tag
|
||||||
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 📤 Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
|
with:
|
||||||
|
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
|
||||||
|
path: build/*.ipa
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
# Disabled for now - uncomment when ready to build iOS TV
|
# Disabled for now - uncomment when ready to build iOS TV
|
||||||
# build-ios-tv:
|
# build-ios-tv:
|
||||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
@@ -271,9 +349,6 @@ jobs:
|
|||||||
# token: ${{ secrets.EXPO_TOKEN }}
|
# token: ${{ secrets.EXPO_TOKEN }}
|
||||||
# eas-cache: true
|
# eas-cache: true
|
||||||
#
|
#
|
||||||
# - name: ⚙️ Ensure tvOS SDKs installed
|
|
||||||
# run: xcodebuild -downloadPlatform tvOS
|
|
||||||
#
|
|
||||||
# - name: 🚀 Build iOS app
|
# - name: 🚀 Build iOS app
|
||||||
# env:
|
# env:
|
||||||
# EXPO_TV: 1
|
# EXPO_TV: 1
|
||||||
|
|||||||
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -27,12 +27,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|||||||
9
.github/workflows/crowdin.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
|
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
@@ -40,11 +40,12 @@ jobs:
|
|||||||
pull_request_base_branch_name: "develop"
|
pull_request_base_branch_name: "develop"
|
||||||
pull_request_labels: "🌐 translation"
|
pull_request_labels: "🌐 translation"
|
||||||
# Quality control options
|
# Quality control options
|
||||||
skip_untranslated_strings: true
|
skip_untranslated_strings: false
|
||||||
|
skip_untranslated_files: false
|
||||||
export_only_approved: false
|
export_only_approved: false
|
||||||
# Commit customization
|
# Commit customization
|
||||||
commit_message: "feat(i18n): update translations from Crowdin"
|
commit_message: "feat(i18n): update translations from Crowdin"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
|
|||||||
12
.github/workflows/linting.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
@@ -100,19 +100,19 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
|
|||||||
1
.github/workflows/notification.yml
vendored
@@ -1,4 +1,5 @@
|
|||||||
name: 🛎️ Discord Notification
|
name: 🛎️ Discord Notification
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|||||||
49
.github/workflows/stale.yml
vendored
@@ -1,49 +0,0 @@
|
|||||||
name: 🕒 Handle Stale Issues
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
|
|
||||||
- cron: "30 1 * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale-issues:
|
|
||||||
name: 🗑️ Cleanup Stale Issues
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 🔄 Mark/Close Stale Issues
|
|
||||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
|
||||||
with:
|
|
||||||
# Global settings
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
operations-per-run: 500 # Increase if you have >1000 issues
|
|
||||||
enable-statistics: true
|
|
||||||
|
|
||||||
# Issue configuration
|
|
||||||
days-before-issue-stale: 90
|
|
||||||
days-before-issue-close: 7
|
|
||||||
stale-issue-label: "🕰️ stale"
|
|
||||||
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
|
||||||
|
|
||||||
# Notifications messages
|
|
||||||
stale-issue-message: |
|
|
||||||
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
|
|
||||||
|
|
||||||
**Next steps:**
|
|
||||||
- If this is still relevant, add a comment to keep it open
|
|
||||||
- Otherwise, it will be closed in 7 days
|
|
||||||
|
|
||||||
Thank you for your contributions! 🙌
|
|
||||||
|
|
||||||
close-issue-message: |
|
|
||||||
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
|
|
||||||
|
|
||||||
**Need to reopen?**
|
|
||||||
Click "Reopen" and add a comment explaining why this should stay open.
|
|
||||||
|
|
||||||
# Disable PR handling
|
|
||||||
days-before-pr-stale: -1
|
|
||||||
days-before-pr-close: -1
|
|
||||||
6
.github/workflows/update-issue-form.yml
vendored
@@ -18,10 +18,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -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@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci-update-bug-report
|
branch: ci-update-bug-report
|
||||||
|
|||||||
12
.gitignore
vendored
@@ -19,7 +19,7 @@ web-build/
|
|||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
# Module-specific Builds
|
# Module-specific Builds
|
||||||
modules/vlc-player/android/build
|
modules/mpv-player/android/build
|
||||||
modules/player/android
|
modules/player/android
|
||||||
modules/hls-downloader/android/build
|
modules/hls-downloader/android/build
|
||||||
|
|
||||||
@@ -50,8 +50,6 @@ npm-debug.*
|
|||||||
.idea/
|
.idea/
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
.cursor/
|
.cursor/
|
||||||
.claude/
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# Environment and Configuration
|
# Environment and Configuration
|
||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
@@ -63,10 +61,16 @@ 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-*
|
||||||
modules/background-downloader/android/build/*
|
|
||||||
/modules/sf-player/android/build
|
/modules/sf-player/android/build
|
||||||
/modules/music-controls/android/build
|
/modules/music-controls/android/build
|
||||||
|
modules/background-downloader/android/build/*
|
||||||
/modules/mpv-player/android/build
|
/modules/mpv-player/android/build
|
||||||
|
|
||||||
|
# ios:unsigned-build Artifacts
|
||||||
|
build/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
171
CLAUDE.md
@@ -2,6 +2,38 @@
|
|||||||
|
|
||||||
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.
|
||||||
@@ -63,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`
|
||||||
|
|
||||||
@@ -75,6 +108,21 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
|
|||||||
- File-based routing in `app/` directory
|
- File-based routing in `app/` directory
|
||||||
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
||||||
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
||||||
|
- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation:
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ❌ Never use this
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Offline Mode**:
|
||||||
|
- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content
|
||||||
|
- Use `useOfflineMode()` hook to check if current context is offline
|
||||||
|
- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context
|
||||||
|
|
||||||
**Providers** (wrapping order in `app/_layout.tsx`):
|
**Providers** (wrapping order in `app/_layout.tsx`):
|
||||||
1. JotaiProvider
|
1. JotaiProvider
|
||||||
@@ -111,9 +159,132 @@ 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
|
||||||
|
|
||||||
- TV version uses `:tv` suffix for scripts
|
- TV version uses `:tv` suffix for scripts
|
||||||
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
||||||
- Some features disabled on TV (e.g., notifications, Chromecast)
|
- Some features disabled on TV (e.g., notifications, Chromecast)
|
||||||
|
- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays.
|
||||||
|
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
|
||||||
|
- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency.
|
||||||
|
- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
|
||||||
|
- **TV Modals**: Never use React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.**
|
||||||
|
|
||||||
|
### TV Component Rendering Pattern
|
||||||
|
|
||||||
|
**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead.
|
||||||
|
|
||||||
|
**Pattern for TV-specific pages and components**:
|
||||||
|
```typescript
|
||||||
|
// In page file (e.g., app/login.tsx)
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { Login } from "@/components/login/Login";
|
||||||
|
import { TVLogin } from "@/components/login/TVLogin";
|
||||||
|
|
||||||
|
const LoginPage: React.FC = () => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVLogin />;
|
||||||
|
}
|
||||||
|
return <Login />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
|
||||||
|
- Use `Platform.isTV` to conditionally render the appropriate component
|
||||||
|
- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling
|
||||||
|
- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly
|
||||||
|
|
||||||
|
### TV Option Selectors and Focus Management
|
||||||
|
|
||||||
|
For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md).
|
||||||
|
|
||||||
|
### TV Focus Flickering Between Zones (Lists with Headers)
|
||||||
|
|
||||||
|
When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
|
||||||
|
1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check:
|
||||||
|
```typescript
|
||||||
|
{Platform.isTV ? (
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FlashList data={items} renderItem={renderItem} />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.
|
||||||
|
|
||||||
|
3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - only first filter button has preferred focus
|
||||||
|
<TVFilterButton hasTVPreferredFocus={index === 0} />
|
||||||
|
<TVFocusablePoster /> // No hasTVPreferredFocus
|
||||||
|
|
||||||
|
// ❌ Bad - both compete for focus
|
||||||
|
<TVFilterButton hasTVPreferredFocus />
|
||||||
|
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
|
||||||
|
```typescript
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Filter bar - separate from list */}
|
||||||
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||||
|
<TVFilterButton />
|
||||||
|
<TVFilterButton />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<FlatList data={items} renderItem={renderTVItem} />
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
|
||||||
|
|
||||||
|
**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.
|
||||||
|
|
||||||
|
### TV Focus Guide Navigation (Non-Adjacent Sections)
|
||||||
|
|
||||||
|
When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use `TVFocusGuideView` with the `destinations` prop:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Track destination with useState (NOT useRef - won't trigger re-renders)
|
||||||
|
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
// 2. Place invisible focus guide between sections
|
||||||
|
{firstCardRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[firstCardRef]}
|
||||||
|
style={{ height: 1, width: "100%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
// 3. Target component must use forwardRef
|
||||||
|
const MyCard = React.forwardRef<View, Props>(({ ... }, ref) => (
|
||||||
|
<Pressable ref={ref} ...>
|
||||||
|
...
|
||||||
|
</Pressable>
|
||||||
|
));
|
||||||
|
|
||||||
|
// 4. Pass state setter as callback ref to first item
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<MyCard
|
||||||
|
ref={index === 0 ? setFirstCardRef : undefined}
|
||||||
|
...
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For detailed documentation and bidirectional navigation patterns, see [docs/tv-focus-guide.md](docs/tv-focus-guide.md)**
|
||||||
|
|
||||||
|
**Reference implementation**: See `components/ItemContent.tv.tsx` for bidirectional focus navigation between playback options and cast list.
|
||||||
|
|||||||
12
README.md
@@ -5,6 +5,12 @@
|
|||||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/aJvAYeycyY">
|
||||||
|
<img alt="Streamyfin Discord" src="https://img.shields.io/badge/Discord-Streamyfin-blue?style=flat-square&logo=discord">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -54,6 +60,11 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
|
|||||||
|
|
||||||
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
||||||
|
|
||||||
|
### 🎬 MPV Player
|
||||||
|
|
||||||
|
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
|
||||||
|
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
|
||||||
|
|
||||||
### 🔍 Jellysearch
|
### 🔍 Jellysearch
|
||||||
|
|
||||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
||||||
@@ -230,6 +241,7 @@ We also thank all other developers who have contributed to Streamyfin, your effo
|
|||||||
|
|
||||||
A special mention to the following people and projects for their contributions:
|
A special mention to the following people and projects for their contributions:
|
||||||
|
|
||||||
|
- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native
|
||||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
|
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
|
||||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
|
||||||
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
|
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// KSPlayer for iOS (GPU acceleration + native PiP)
|
|
||||||
config.plugins.push("./plugins/withKSPlayer.js");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
|
|||||||
55
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.51.0",
|
"version": "0.52.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -17,24 +17,29 @@
|
|||||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||||
"UIBackgroundModes": ["audio", "fetch"],
|
"UIBackgroundModes": ["audio", "fetch"],
|
||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
|
"NSLocationWhenInUseUsageDescription": "Streamyfin uses your location to detect your home WiFi network for automatic local server switching.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
},
|
},
|
||||||
"UISupportsTrueScreenSizeOnMac": true,
|
"UISupportsTrueScreenSizeOnMac": true,
|
||||||
"UIFileSharingEnabled": true,
|
"UIFileSharingEnabled": true,
|
||||||
"LSSupportsOpeningDocumentsInPlace": true
|
"LSSupportsOpeningDocumentsInPlace": true,
|
||||||
|
"AVInitialRouteSharingPolicy": "LongFormAudio"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.developer.networking.wifi-info": true
|
||||||
|
},
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 91,
|
"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",
|
||||||
@@ -44,21 +49,40 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
"android.permission.WRITE_SETTINGS"
|
"android.permission.WRITE_SETTINGS",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION"
|
||||||
],
|
],
|
||||||
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
||||||
"googleServicesFile": "./google-services.json"
|
"googleServicesFile": "./google-services.json"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@react-native-tvos/config-tv",
|
[
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
|
{
|
||||||
|
"appleTVImages": {
|
||||||
|
"icon": "./assets/images/icon-tvos.png",
|
||||||
|
"iconSmall": "./assets/images/icon-tvos-small.png",
|
||||||
|
"iconSmall2x": "./assets/images/icon-tvos-small-2x.png",
|
||||||
|
"topShelf": "./assets/images/icon-tvos-topshelf.png",
|
||||||
|
"topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png",
|
||||||
|
"topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png",
|
||||||
|
"topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png"
|
||||||
|
},
|
||||||
|
"infoPlist": {
|
||||||
|
"UIAppSupportsHDR": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.js",
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
|
"./plugins/withTVUserManagement.js",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6"
|
"deploymentTarget": "15.6",
|
||||||
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||||
@@ -66,7 +90,7 @@
|
|||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
"kotlinVersion": "2.0.21",
|
"kotlinVersion": "2.0.21",
|
||||||
"minSdkVersion": 24,
|
"minSdkVersion": 26,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
"jniLibs": {
|
"jniLibs": {
|
||||||
@@ -84,12 +108,6 @@
|
|||||||
"initialOrientation": "DEFAULT"
|
"initialOrientation": "DEFAULT"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"expo-sensors",
|
|
||||||
{
|
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expo-localization",
|
"expo-localization",
|
||||||
"expo-asset",
|
"expo-asset",
|
||||||
[
|
[
|
||||||
@@ -120,7 +138,16 @@
|
|||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"]
|
["./plugins/withGradleProperties.js"],
|
||||||
|
["./plugins/withTVOSAppIcon.js"],
|
||||||
|
["./plugins/withTVXcodeEnv.js"],
|
||||||
|
[
|
||||||
|
"./plugins/withGitPod.js",
|
||||||
|
{
|
||||||
|
"podName": "MPVKit-GPL",
|
||||||
|
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: Platform.OS !== "ios",
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.custom_links"),
|
headerTitle: t("tabs.custom_links"),
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function menuLinks() {
|
|||||||
);
|
);
|
||||||
const config = response?.data;
|
const config = response?.data;
|
||||||
|
|
||||||
if (!config && !Object.hasOwn(config, "menuLinks")) {
|
if (!config || !Object.hasOwn(config, "menuLinks")) {
|
||||||
console.error("Menu links not found");
|
console.error("Menu links not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
|||||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Favorites } from "@/components/home/Favorites";
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
|
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
|
||||||
export default function favorites() {
|
export default function favorites() {
|
||||||
@@ -15,6 +16,10 @@ export default function favorites() {
|
|||||||
}, []);
|
}, []);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVFavorites />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
|
||||||
@@ -41,37 +43,18 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/index'
|
name='downloads/index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='downloads/[seriesId]'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
title: t("home.downloads.tvseries"),
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -79,18 +62,18 @@ export default function IndexLayout() {
|
|||||||
name='sessions/index'
|
name='sessions/index'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.sessions.title"),
|
title: t("home.sessions.title"),
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -98,17 +81,18 @@ export default function IndexLayout() {
|
|||||||
name='settings'
|
name='settings'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -116,17 +100,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/playback-controls/page'
|
name='settings/playback-controls/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.playback_controls.title"),
|
title: t("home.settings.playback_controls.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -134,17 +119,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/audio-subtitles/page'
|
name='settings/audio-subtitles/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.audio_subtitles.title"),
|
title: t("home.settings.audio_subtitles.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -152,17 +138,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/appearance/page'
|
name='settings/appearance/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.appearance.title"),
|
title: t("home.settings.appearance.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -170,17 +157,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/music/page'
|
name='settings/music/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.music.title"),
|
title: t("home.settings.music.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -188,17 +176,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/appearance/hide-libraries/page'
|
name='settings/appearance/hide-libraries/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.other.hide_libraries"),
|
title: t("home.settings.other.hide_libraries"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -206,17 +195,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/page'
|
name='settings/plugins/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.plugins.plugins_title"),
|
title: t("home.settings.plugins.plugins_title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -224,17 +214,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/marlin-search/page'
|
name='settings/plugins/marlin-search/page'
|
||||||
options={{
|
options={{
|
||||||
title: "Marlin Search",
|
title: "Marlin Search",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -242,17 +233,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/jellyseerr/page'
|
name='settings/plugins/jellyseerr/page'
|
||||||
options={{
|
options={{
|
||||||
title: "Jellyseerr",
|
title: "Jellyseerr",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -260,17 +252,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/streamystats/page'
|
name='settings/plugins/streamystats/page'
|
||||||
options={{
|
options={{
|
||||||
title: "Streamystats",
|
title: "Streamystats",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -278,17 +271,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/kefinTweaks/page'
|
name='settings/plugins/kefinTweaks/page'
|
||||||
options={{
|
options={{
|
||||||
title: "KefinTweaks",
|
title: "KefinTweaks",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -296,17 +290,18 @@ export default function IndexLayout() {
|
|||||||
name='settings/intro/page'
|
name='settings/intro/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.intro.title"),
|
title: t("home.settings.intro.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -314,31 +309,38 @@ export default function IndexLayout() {
|
|||||||
name='settings/logs/page'
|
name='settings/logs/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.logs.logs_title"),
|
title: t("home.settings.logs.logs_title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='intro/page'
|
name='settings/network/page'
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
title: t("home.settings.network.title"),
|
||||||
title: "",
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
presentation: "modal",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
@@ -349,11 +351,11 @@ export default function IndexLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -367,13 +369,13 @@ const SettingsButton = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Feather name='settings' color={"white"} size={22} />
|
<Feather name='settings' color={"white"} size={22} />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -382,7 +384,7 @@ const SessionsButton = () => {
|
|||||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/sessions");
|
router.push("/(auth)/sessions");
|
||||||
}}
|
}}
|
||||||
@@ -393,6 +395,6 @@ const SessionsButton = () => {
|
|||||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
|
||||||
import {
|
|
||||||
SeasonDropdown,
|
|
||||||
type SeasonIndexState,
|
|
||||||
} from "@/components/series/SeasonDropdown";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const { seriesId, episodeSeasonIndex } = local as {
|
|
||||||
seriesId: string;
|
|
||||||
episodeSeasonIndex: number | string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
const { downloadedItems, deleteItems } = useDownload();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const series = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
downloadedItems
|
|
||||||
?.filter((f) => f.item.SeriesId === seriesId)
|
|
||||||
?.sort(
|
|
||||||
(a, b) =>
|
|
||||||
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedItems, seriesId]);
|
|
||||||
|
|
||||||
// Group episodes by season in a single pass
|
|
||||||
const seasonGroups = useMemo(() => {
|
|
||||||
const groups: Record<number, BaseItemDto[]> = {};
|
|
||||||
|
|
||||||
series.forEach((episode) => {
|
|
||||||
const seasonNumber = episode.item.ParentIndexNumber;
|
|
||||||
if (seasonNumber !== undefined && seasonNumber !== null) {
|
|
||||||
if (!groups[seasonNumber]) {
|
|
||||||
groups[seasonNumber] = [];
|
|
||||||
}
|
|
||||||
groups[seasonNumber].push(episode.item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort episodes within each season
|
|
||||||
Object.values(groups).forEach((episodes) => {
|
|
||||||
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}, [series]);
|
|
||||||
|
|
||||||
// Get unique seasons (just the season numbers, sorted)
|
|
||||||
const uniqueSeasons = useMemo(() => {
|
|
||||||
const seasonNumbers = Object.keys(seasonGroups)
|
|
||||||
.map(Number)
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
|
|
||||||
}, [seasonGroups]);
|
|
||||||
|
|
||||||
const seasonIndex =
|
|
||||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
|
|
||||||
episodeSeasonIndex ??
|
|
||||||
series?.[0]?.item?.ParentIndexNumber ??
|
|
||||||
"";
|
|
||||||
|
|
||||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
|
||||||
return seasonGroups[Number(seasonIndex)] ?? [];
|
|
||||||
}, [seasonGroups, seasonIndex]);
|
|
||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
|
||||||
() =>
|
|
||||||
groupBySeason?.[0]?.ParentIndexNumber ??
|
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
|
||||||
[groupBySeason, series],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (series.length > 0) {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: series[0].item.SeriesName,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
storage.remove(seriesId);
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
}, [series]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(() => {
|
|
||||||
Alert.alert(
|
|
||||||
"Delete season",
|
|
||||||
"Are you sure you want to delete the entire season?",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Cancel",
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Delete",
|
|
||||||
onPress: () =>
|
|
||||||
deleteItems(
|
|
||||||
groupBySeason
|
|
||||||
.map((item) => item.Id)
|
|
||||||
.filter((id) => id !== undefined),
|
|
||||||
),
|
|
||||||
style: "destructive",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}, [groupBySeason, deleteItems]);
|
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(() => {
|
|
||||||
if (series.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex flex-row items-center justify-start pb-2'>
|
|
||||||
<SeasonDropdown
|
|
||||||
item={series[0].item}
|
|
||||||
seasons={uniqueSeasons}
|
|
||||||
state={seasonIndexState}
|
|
||||||
initialSeasonIndex={initialSeasonIndex!}
|
|
||||||
onSelect={(season) => {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
|
||||||
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
|
||||||
</View>
|
|
||||||
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
|
||||||
<TouchableOpacity onPress={deleteSeries}>
|
|
||||||
<Ionicons name='trash' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
series,
|
|
||||||
uniqueSeasons,
|
|
||||||
seasonIndexState,
|
|
||||||
initialSeasonIndex,
|
|
||||||
groupBySeason,
|
|
||||||
deleteSeries,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex-1'>
|
|
||||||
<FlashList
|
|
||||||
key={seasonIndex}
|
|
||||||
data={groupBySeason}
|
|
||||||
renderItem={({ item }) => <EpisodeCard item={item} />}
|
|
||||||
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
|
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingLeft: insets.left + 16,
|
|
||||||
paddingRight: insets.right + 16,
|
|
||||||
paddingTop: Platform.OS === "android" ? 10 : 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Alert, Platform, ScrollView, View } from "react-native";
|
||||||
Alert,
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -18,8 +13,10 @@ import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
|||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -103,12 +100,12 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={bottomSheetModalRef.current?.present}
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
className='px-2'
|
className='px-2'
|
||||||
>
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
@@ -166,145 +163,99 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<OfflineModeProvider isOffline={true}>
|
||||||
showsVerticalScrollIndicator={false}
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
>
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||||
{/* Queue card - hidden */}
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
|
<ActiveDownloads />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{movies.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.queue")}
|
{t("home.downloads.movies")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-xs opacity-70 text-red-600'>
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
{t("home.downloads.queue_hint")}
|
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||||
</Text>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 mt-2'>
|
</View>
|
||||||
{queue.map((q, index) => (
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<TouchableOpacity
|
<View className='px-4 flex flex-row'>
|
||||||
onPress={() =>
|
{movies?.map((item) => (
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||||
}
|
<MovieCard item={item.item} />
|
||||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
</TouchableItemRouter>
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className='font-semibold'>{q.item.Name}</Text>
|
|
||||||
<Text className='text-xs opacity-50'>
|
|
||||||
{q.item.Type}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeProcess(q.id);
|
|
||||||
setQueue((prev) => {
|
|
||||||
if (!prev) return [];
|
|
||||||
return [...prev.filter((i) => i.id !== q.id)];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='close' size={24} color='red' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className='opacity-50'>
|
|
||||||
{t("home.downloads.no_items_in_queue")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View> */}
|
|
||||||
|
|
||||||
<ActiveDownloads />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.movies")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
)}
|
||||||
<View className='px-4 flex flex-row'>
|
{groupedBySeries.length > 0 && (
|
||||||
{movies?.map((item) => (
|
<View className='mb-4'>
|
||||||
<TouchableItemRouter
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
item={item.item}
|
<Text className='text-lg font-bold'>
|
||||||
isOffline
|
{t("home.downloads.tvseries")}
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.tvseries")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{groupedBySeries?.length}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>
|
||||||
|
{groupedBySeries?.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<View className='px-4 flex flex-row'>
|
||||||
<View className='px-4 flex flex-row'>
|
{groupedBySeries?.map((items) => (
|
||||||
{groupedBySeries?.map((items) => (
|
<View
|
||||||
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
|
className='mb-2 last:mb-0'
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
key={items[0].item.SeriesId}
|
||||||
/>
|
>
|
||||||
</View>
|
<SeriesCard
|
||||||
))}
|
items={items.map((i) => i.item)}
|
||||||
</View>
|
key={items[0].item.SeriesId}
|
||||||
</ScrollView>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
))}
|
||||||
|
</View>
|
||||||
{otherMedia.length > 0 && (
|
</ScrollView>
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.other_media")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
)}
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{otherMedia?.map((item) => (
|
{otherMedia.length > 0 && (
|
||||||
<TouchableItemRouter
|
<View className='mb-4'>
|
||||||
item={item.item}
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
isOffline
|
<Text className='text-lg font-bold'>
|
||||||
key={item.item.Id}
|
{t("home.downloads.other_media")}
|
||||||
>
|
</Text>
|
||||||
<MovieCard item={item.item} />
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</TouchableItemRouter>
|
<Text className='text-xs font-bold'>
|
||||||
))}
|
{otherMedia?.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
</View>
|
<View className='px-4 flex flex-row'>
|
||||||
)}
|
{otherMedia?.map((item) => (
|
||||||
{downloadedFiles?.length === 0 && (
|
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||||
<View className='flex px-4'>
|
<MovieCard item={item.item} />
|
||||||
<Text className='opacity-50'>
|
</TouchableItemRouter>
|
||||||
{t("home.downloads.no_downloaded_items")}
|
))}
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
)}
|
</View>
|
||||||
</View>
|
)}
|
||||||
</ScrollView>
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Home } from "../../../../components/home/Home";
|
import { Home } from "../../../../components/home/Home";
|
||||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const { settings } = useSettings();
|
|
||||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
|
||||||
|
|
||||||
if (showLargeHomeCarousel) {
|
|
||||||
return <HomeWithCarousel />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Home />;
|
return <Home />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
storage.set("hasShownIntro", true);
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className='text-3xl font-bold text-center mb-2'>
|
|
||||||
{t("home.intro.welcome_to_streamyfin")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-center'>
|
|
||||||
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.intro.features_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
|
|
||||||
<View className='flex flex-row items-center mt-4'>
|
|
||||||
<Image
|
|
||||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className='shrink ml-2'>
|
|
||||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
|
||||||
<Text className='shrink text-xs'>
|
|
||||||
{t("home.intro.jellyseerr_feature_description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<>
|
|
||||||
<View className='flex flex-row items-center mt-4'>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
}}
|
|
||||||
className='flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='cloud-download-outline'
|
|
||||||
size={32}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className='shrink ml-2'>
|
|
||||||
<Text className='font-bold mb-1'>
|
|
||||||
{t("home.intro.downloads_feature_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='shrink text-xs'>
|
|
||||||
{t("home.intro.downloads_feature_description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className='flex flex-row items-center mt-4'>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
}}
|
|
||||||
className='flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<Feather name='cast' size={28} color={"white"} />
|
|
||||||
</View>
|
|
||||||
<View className='shrink ml-2'>
|
|
||||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
|
||||||
<Text className='shrink text-xs'>
|
|
||||||
{t("home.intro.chromecast_feature_description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<View className='flex flex-row items-center mt-4'>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
}}
|
|
||||||
className='flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<Feather name='settings' size={28} color={"white"} />
|
|
||||||
</View>
|
|
||||||
<View className='shrink ml-2'>
|
|
||||||
<Text className='font-bold mb-1'>
|
|
||||||
{t("home.intro.centralised_settings_plugin_title")}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row flex-wrap items-baseline'>
|
|
||||||
<Text className='shrink text-xs'>
|
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
Linking.openURL(
|
|
||||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-xs text-purple-600 underline'>
|
|
||||||
{t("home.intro.read_more")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className='mt-4'
|
|
||||||
>
|
|
||||||
{t("home.intro.done_button")}
|
|
||||||
</Button>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
router.push("/settings");
|
|
||||||
}}
|
|
||||||
className='mt-4'
|
|
||||||
>
|
|
||||||
<Text className='text-purple-600 text-center'>
|
|
||||||
{t("home.intro.go_to_settings_button")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -11,9 +11,14 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
|||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function settings() {
|
// TV-specific settings component
|
||||||
|
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
||||||
|
|
||||||
|
// Mobile settings component
|
||||||
|
function SettingsMobile() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [_user] = useAtom(userAtom);
|
const [_user] = useAtom(userAtom);
|
||||||
@@ -90,6 +95,11 @@ export default function settings() {
|
|||||||
showArrow
|
showArrow
|
||||||
title={t("home.settings.intro.title")}
|
title={t("home.settings.intro.title")}
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/network/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.network.title")}
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
showArrow
|
showArrow
|
||||||
@@ -98,8 +108,17 @@ export default function settings() {
|
|||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!Platform.isTV && <StorageSettings />}
|
<StorageSettings />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function settings() {
|
||||||
|
// Use TV settings component on TV platforms
|
||||||
|
if (Platform.isTV && SettingsTV) {
|
||||||
|
return <SettingsTV />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SettingsMobile />;
|
||||||
|
}
|
||||||
|
|||||||
809
app/(auth)/(tabs)/(home)/settings.tv.tsx
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
|
import type { TVOptionItem } from "@/components/tv";
|
||||||
|
import {
|
||||||
|
TVLogoutButton,
|
||||||
|
TVSectionHeader,
|
||||||
|
TVSettingsOptionButton,
|
||||||
|
TVSettingsRow,
|
||||||
|
TVSettingsStepper,
|
||||||
|
TVSettingsTextInput,
|
||||||
|
TVSettingsToggle,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||||
|
import { APP_LANGUAGES } from "@/i18n";
|
||||||
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
AudioTranscodeMode,
|
||||||
|
InactivityTimeout,
|
||||||
|
type MpvCacheMode,
|
||||||
|
TVTypographyScale,
|
||||||
|
useSettings,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
getPreviousServers,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
export default function SettingsTV() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
|
settings.openSubtitlesApiKey || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// PIN/Password modal state for user switching
|
||||||
|
const [pinModalVisible, setPinModalVisible] = useState(false);
|
||||||
|
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
||||||
|
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [selectedAccount, setSelectedAccount] =
|
||||||
|
useState<SavedServerAccount | null>(null);
|
||||||
|
|
||||||
|
// Track if any modal is open to disable background focus
|
||||||
|
const isAnyModalOpen = pinModalVisible || passwordModalVisible;
|
||||||
|
|
||||||
|
// Get current server and other accounts
|
||||||
|
const currentServer = useMemo(() => {
|
||||||
|
if (!api?.basePath) return null;
|
||||||
|
const servers = getPreviousServers();
|
||||||
|
return servers.find((s) => s.address === api.basePath) || null;
|
||||||
|
}, [api?.basePath]);
|
||||||
|
|
||||||
|
const otherAccounts = useMemo(() => {
|
||||||
|
if (!currentServer || !user?.Id) return [];
|
||||||
|
return currentServer.accounts.filter(
|
||||||
|
(account) => account.userId !== user.Id,
|
||||||
|
);
|
||||||
|
}, [currentServer, user?.Id]);
|
||||||
|
|
||||||
|
const hasOtherAccounts = otherAccounts.length > 0;
|
||||||
|
|
||||||
|
// Handle account selection from modal
|
||||||
|
const handleAccountSelect = async (account: SavedServerAccount) => {
|
||||||
|
if (!currentServer) return;
|
||||||
|
|
||||||
|
if (account.securityType === "none") {
|
||||||
|
// Direct login with saved credential
|
||||||
|
try {
|
||||||
|
await loginWithSavedCredential(currentServer.address, account.userId);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : t("server.session_expired");
|
||||||
|
const isSessionExpired = errorMessage.includes(
|
||||||
|
t("server.session_expired"),
|
||||||
|
);
|
||||||
|
Alert.alert(
|
||||||
|
isSessionExpired
|
||||||
|
? t("server.session_expired")
|
||||||
|
: t("login.connection_failed"),
|
||||||
|
isSessionExpired ? t("server.please_login_again") : errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (account.securityType === "pin") {
|
||||||
|
// Show PIN modal
|
||||||
|
setSelectedServer(currentServer);
|
||||||
|
setSelectedAccount(account);
|
||||||
|
setPinModalVisible(true);
|
||||||
|
} else if (account.securityType === "password") {
|
||||||
|
// Show password modal
|
||||||
|
setSelectedServer(currentServer);
|
||||||
|
setSelectedAccount(account);
|
||||||
|
setPasswordModalVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle successful PIN entry
|
||||||
|
const handlePinSuccess = async () => {
|
||||||
|
setPinModalVisible(false);
|
||||||
|
if (selectedServer && selectedAccount) {
|
||||||
|
try {
|
||||||
|
await loginWithSavedCredential(
|
||||||
|
selectedServer.address,
|
||||||
|
selectedAccount.userId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : t("server.session_expired");
|
||||||
|
const isSessionExpired = errorMessage.includes(
|
||||||
|
t("server.session_expired"),
|
||||||
|
);
|
||||||
|
Alert.alert(
|
||||||
|
isSessionExpired
|
||||||
|
? t("server.session_expired")
|
||||||
|
: t("login.connection_failed"),
|
||||||
|
isSessionExpired ? t("server.please_login_again") : errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedServer(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle password submission
|
||||||
|
const handlePasswordSubmit = async (password: string) => {
|
||||||
|
if (selectedServer && selectedAccount) {
|
||||||
|
await loginWithPassword(
|
||||||
|
selectedServer.address,
|
||||||
|
selectedAccount.username,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
setSelectedServer(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle switch user button press
|
||||||
|
const handleSwitchUser = () => {
|
||||||
|
if (!currentServer || !user?.Id) return;
|
||||||
|
showUserSwitchModal(currentServer, user.Id, {
|
||||||
|
onAccountSelect: handleAccountSelect,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentAudioTranscode =
|
||||||
|
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
|
||||||
|
const currentSubtitleMode =
|
||||||
|
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
||||||
|
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
||||||
|
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||||
|
const currentTypographyScale =
|
||||||
|
settings.tvTypographyScale || TVTypographyScale.Default;
|
||||||
|
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
|
||||||
|
const currentLanguage = settings.preferedLanguage;
|
||||||
|
|
||||||
|
// Audio transcoding options
|
||||||
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.auto"),
|
||||||
|
value: AudioTranscodeMode.Auto,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.Auto,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.stereo"),
|
||||||
|
value: AudioTranscodeMode.ForceStereo,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.ForceStereo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.5_1"),
|
||||||
|
value: AudioTranscodeMode.Allow51,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.Allow51,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.passthrough"),
|
||||||
|
value: AudioTranscodeMode.AllowAll,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.AllowAll,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentAudioTranscode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subtitle mode options
|
||||||
|
const subtitleModeOptions: TVOptionItem<SubtitlePlaybackMode>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.Default"),
|
||||||
|
value: SubtitlePlaybackMode.Default,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.Default,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.Smart"),
|
||||||
|
value: SubtitlePlaybackMode.Smart,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.Smart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.OnlyForced"),
|
||||||
|
value: SubtitlePlaybackMode.OnlyForced,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.OnlyForced,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.Always"),
|
||||||
|
value: SubtitlePlaybackMode.Always,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.Always,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.None"),
|
||||||
|
value: SubtitlePlaybackMode.None,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentSubtitleMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// MPV alignment options
|
||||||
|
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Left", value: "left", selected: currentAlignX === "left" },
|
||||||
|
{
|
||||||
|
label: "Center",
|
||||||
|
value: "center",
|
||||||
|
selected: currentAlignX === "center",
|
||||||
|
},
|
||||||
|
{ label: "Right", value: "right", selected: currentAlignX === "right" },
|
||||||
|
],
|
||||||
|
[currentAlignX],
|
||||||
|
);
|
||||||
|
|
||||||
|
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Top", value: "top", selected: currentAlignY === "top" },
|
||||||
|
{
|
||||||
|
label: "Center",
|
||||||
|
value: "center",
|
||||||
|
selected: currentAlignY === "center",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Bottom",
|
||||||
|
value: "bottom",
|
||||||
|
selected: currentAlignY === "bottom",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[currentAlignY],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache mode options
|
||||||
|
const cacheModeOptions: TVOptionItem<MpvCacheMode>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.buffer.cache_auto"),
|
||||||
|
value: "auto",
|
||||||
|
selected: currentCacheMode === "auto",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.buffer.cache_yes"),
|
||||||
|
value: "yes",
|
||||||
|
selected: currentCacheMode === "yes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.buffer.cache_no"),
|
||||||
|
value: "no",
|
||||||
|
selected: currentCacheMode === "no",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentCacheMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Typography scale options
|
||||||
|
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.display_size_small"),
|
||||||
|
value: TVTypographyScale.Small,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.Small,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.display_size_default"),
|
||||||
|
value: TVTypographyScale.Default,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.Default,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.display_size_large"),
|
||||||
|
value: TVTypographyScale.Large,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.Large,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.display_size_extra_large"),
|
||||||
|
value: TVTypographyScale.ExtraLarge,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentTypographyScale],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Language options
|
||||||
|
const languageOptions: TVOptionItem<string | undefined>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.languages.system"),
|
||||||
|
value: undefined,
|
||||||
|
selected: !currentLanguage,
|
||||||
|
},
|
||||||
|
...APP_LANGUAGES.map((lang) => ({
|
||||||
|
label: lang.label,
|
||||||
|
value: lang.value,
|
||||||
|
selected: currentLanguage === lang.value,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[t, currentLanguage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inactivity timeout options (TV security feature)
|
||||||
|
const currentInactivityTimeout =
|
||||||
|
settings.inactivityTimeout ?? InactivityTimeout.Disabled;
|
||||||
|
|
||||||
|
const inactivityTimeoutOptions: TVOptionItem<InactivityTimeout>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.security.inactivity_timeout.disabled"),
|
||||||
|
value: InactivityTimeout.Disabled,
|
||||||
|
selected: currentInactivityTimeout === InactivityTimeout.Disabled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.security.inactivity_timeout.1_minute"),
|
||||||
|
value: InactivityTimeout.OneMinute,
|
||||||
|
selected: currentInactivityTimeout === InactivityTimeout.OneMinute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.security.inactivity_timeout.5_minutes"),
|
||||||
|
value: InactivityTimeout.FiveMinutes,
|
||||||
|
selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.security.inactivity_timeout.15_minutes"),
|
||||||
|
value: InactivityTimeout.FifteenMinutes,
|
||||||
|
selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.security.inactivity_timeout.30_minutes"),
|
||||||
|
value: InactivityTimeout.ThirtyMinutes,
|
||||||
|
selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.security.inactivity_timeout.1_hour"),
|
||||||
|
value: InactivityTimeout.OneHour,
|
||||||
|
selected: currentInactivityTimeout === InactivityTimeout.OneHour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.security.inactivity_timeout.4_hours"),
|
||||||
|
value: InactivityTimeout.FourHours,
|
||||||
|
selected: currentInactivityTimeout === InactivityTimeout.FourHours,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.security.inactivity_timeout.24_hours"),
|
||||||
|
value: InactivityTimeout.TwentyFourHours,
|
||||||
|
selected:
|
||||||
|
currentInactivityTimeout === InactivityTimeout.TwentyFourHours,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentInactivityTimeout],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get display labels for option buttons
|
||||||
|
const audioTranscodeLabel = useMemo(() => {
|
||||||
|
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.audio.transcode_mode.auto");
|
||||||
|
}, [audioTranscodeModeOptions, t]);
|
||||||
|
|
||||||
|
const subtitleModeLabel = useMemo(() => {
|
||||||
|
const option = subtitleModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.subtitles.modes.Default");
|
||||||
|
}, [subtitleModeOptions, t]);
|
||||||
|
|
||||||
|
const alignXLabel = useMemo(() => {
|
||||||
|
const option = alignXOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "Center";
|
||||||
|
}, [alignXOptions]);
|
||||||
|
|
||||||
|
const alignYLabel = useMemo(() => {
|
||||||
|
const option = alignYOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "Bottom";
|
||||||
|
}, [alignYOptions]);
|
||||||
|
|
||||||
|
const typographyScaleLabel = useMemo(() => {
|
||||||
|
const option = typographyScaleOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.appearance.display_size_default");
|
||||||
|
}, [typographyScaleOptions, t]);
|
||||||
|
|
||||||
|
const cacheModeLabel = useMemo(() => {
|
||||||
|
const option = cacheModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.buffer.cache_auto");
|
||||||
|
}, [cacheModeOptions, t]);
|
||||||
|
|
||||||
|
const languageLabel = useMemo(() => {
|
||||||
|
if (!currentLanguage) return t("home.settings.languages.system");
|
||||||
|
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
||||||
|
return option?.label || t("home.settings.languages.system");
|
||||||
|
}, [currentLanguage, t]);
|
||||||
|
|
||||||
|
const inactivityTimeoutLabel = useMemo(() => {
|
||||||
|
const option = inactivityTimeoutOptions.find((o) => o.selected);
|
||||||
|
return (
|
||||||
|
option?.label || t("home.settings.security.inactivity_timeout.disabled")
|
||||||
|
);
|
||||||
|
}, [inactivityTimeoutOptions, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 120,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.title,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.settings_title")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Account Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.switch_user.account")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.switch_user.switch_user")}
|
||||||
|
value={user?.Name || "-"}
|
||||||
|
onPress={handleSwitchUser}
|
||||||
|
disabled={!hasOtherAccounts || isAnyModalOpen}
|
||||||
|
isFirst
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Security Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.security.title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.security.inactivity_timeout.title")}
|
||||||
|
value={inactivityTimeoutLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.security.inactivity_timeout.title"),
|
||||||
|
options: inactivityTimeoutOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({ inactivityTimeout: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Audio Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.audio.transcode_mode.title")}
|
||||||
|
value={audioTranscodeLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.audio.transcode_mode.title"),
|
||||||
|
options: audioTranscodeModeOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({ audioTranscodeMode: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtitles Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.subtitles.subtitle_mode")}
|
||||||
|
value={subtitleModeLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.subtitles.subtitle_mode"),
|
||||||
|
options: subtitleModeOptions,
|
||||||
|
onSelect: (value) => updateSettings({ subtitleMode: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
|
value={settings.rememberSubtitleSelections}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label={t("home.settings.subtitles.subtitle_size")}
|
||||||
|
value={settings.mpvSubtitleScale ?? 1.0}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(
|
||||||
|
0.1,
|
||||||
|
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
|
||||||
|
);
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleScale: Math.round(newValue * 10) / 10,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(
|
||||||
|
3.0,
|
||||||
|
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
|
||||||
|
);
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleScale: Math.round(newValue * 10) / 10,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label='Vertical Margin'
|
||||||
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(
|
||||||
|
0,
|
||||||
|
(settings.mpvSubtitleMarginY ?? 0) - 5,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(
|
||||||
|
100,
|
||||||
|
(settings.mpvSubtitleMarginY ?? 0) + 5,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label='Horizontal Alignment'
|
||||||
|
value={alignXLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: "Horizontal Alignment",
|
||||||
|
options: alignXOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label='Vertical Alignment'
|
||||||
|
value={alignYLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: "Vertical Alignment",
|
||||||
|
options: alignYOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleAlignY: value as "top" | "center" | "bottom",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* OpenSubtitles Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={
|
||||||
|
t("home.settings.subtitles.opensubtitles_title") ||
|
||||||
|
"OpenSubtitles"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#9CA3AF",
|
||||||
|
fontSize: typography.callout - 2,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_hint") ||
|
||||||
|
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
|
||||||
|
</Text>
|
||||||
|
<TVSettingsTextInput
|
||||||
|
label={
|
||||||
|
t("home.settings.subtitles.opensubtitles_api_key") || "API Key"
|
||||||
|
}
|
||||||
|
value={openSubtitlesApiKey}
|
||||||
|
placeholder={
|
||||||
|
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
|
||||||
|
"Enter API key..."
|
||||||
|
}
|
||||||
|
onChangeText={setOpenSubtitlesApiKey}
|
||||||
|
onBlur={() => updateSettings({ openSubtitlesApiKey })}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#6B7280",
|
||||||
|
fontSize: typography.callout - 4,
|
||||||
|
marginTop: 8,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_get_key") ||
|
||||||
|
"Get your free API key at opensubtitles.com/en/consumers"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Buffer Settings Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.buffer.title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.buffer.cache_mode")}
|
||||||
|
value={cacheModeLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.buffer.cache_mode"),
|
||||||
|
options: cacheModeOptions,
|
||||||
|
onSelect: (value) => updateSettings({ mpvCacheEnabled: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label={t("home.settings.buffer.buffer_duration")}
|
||||||
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(
|
||||||
|
5,
|
||||||
|
(settings.mpvCacheSeconds ?? 10) - 5,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvCacheSeconds: newValue });
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(
|
||||||
|
120,
|
||||||
|
(settings.mpvCacheSeconds ?? 10) + 5,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvCacheSeconds: newValue });
|
||||||
|
}}
|
||||||
|
formatValue={(v) => `${v}s`}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label={t("home.settings.buffer.max_cache_size")}
|
||||||
|
value={settings.mpvDemuxerMaxBytes ?? 150}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(
|
||||||
|
50,
|
||||||
|
(settings.mpvDemuxerMaxBytes ?? 150) - 25,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvDemuxerMaxBytes: newValue });
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(
|
||||||
|
500,
|
||||||
|
(settings.mpvDemuxerMaxBytes ?? 150) + 25,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvDemuxerMaxBytes: newValue });
|
||||||
|
}}
|
||||||
|
formatValue={(v) => `${v} MB`}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label={t("home.settings.buffer.max_backward_cache")}
|
||||||
|
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(
|
||||||
|
25,
|
||||||
|
(settings.mpvDemuxerMaxBackBytes ?? 50) - 25,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(
|
||||||
|
200,
|
||||||
|
(settings.mpvDemuxerMaxBackBytes ?? 50) + 25,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
|
||||||
|
}}
|
||||||
|
formatValue={(v) => `${v} MB`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Appearance Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.appearance.display_size")}
|
||||||
|
value={typographyScaleLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.appearance.display_size"),
|
||||||
|
options: typographyScaleOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({ tvTypographyScale: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.languages.app_language")}
|
||||||
|
value={languageLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.languages.app_language"),
|
||||||
|
options: languageOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({ preferedLanguage: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t(
|
||||||
|
"home.settings.appearance.merge_next_up_continue_watching",
|
||||||
|
)}
|
||||||
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
|
value={settings.showHomeBackdrop}
|
||||||
|
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_hero_carousel")}
|
||||||
|
value={settings.showTVHeroCarousel}
|
||||||
|
onToggle={(value) => updateSettings({ showTVHeroCarousel: value })}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_series_poster_on_episode")}
|
||||||
|
value={settings.showSeriesPosterOnEpisode}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ showSeriesPosterOnEpisode: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.theme_music")}
|
||||||
|
value={settings.tvThemeMusicEnabled}
|
||||||
|
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={t("home.settings.user_info.user_info_title")}
|
||||||
|
/>
|
||||||
|
<TVSettingsRow
|
||||||
|
label={t("home.settings.user_info.user")}
|
||||||
|
value={user?.Name || "-"}
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
<TVSettingsRow
|
||||||
|
label={t("home.settings.user_info.server")}
|
||||||
|
value={api?.basePath || "-"}
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<View style={{ marginTop: 48, alignItems: "center" }}>
|
||||||
|
<TVLogoutButton onPress={logout} />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* PIN Entry Modal */}
|
||||||
|
<TVPINEntryModal
|
||||||
|
visible={pinModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setPinModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}}
|
||||||
|
onSuccess={handlePinSuccess}
|
||||||
|
onForgotPIN={() => {
|
||||||
|
setPinModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}}
|
||||||
|
serverUrl={selectedServer?.address || ""}
|
||||||
|
userId={selectedAccount?.userId || ""}
|
||||||
|
username={selectedAccount?.username || ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Entry Modal */}
|
||||||
|
<TVPasswordEntryModal
|
||||||
|
visible={passwordModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handlePasswordSubmit}
|
||||||
|
username={selectedAccount?.username || ""}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Platform, ScrollView, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
|
import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
|
|
||||||
export default function AudioSubtitlesPage() {
|
export default function AudioSubtitlesPage() {
|
||||||
@@ -22,6 +23,7 @@ export default function AudioSubtitlesPage() {
|
|||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<AudioToggles className='mb-4' />
|
<AudioToggles className='mb-4' />
|
||||||
<SubtitleToggles className='mb-4' />
|
<SubtitleToggles className='mb-4' />
|
||||||
|
<MpvSubtitleSettings className='mb-4' />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, 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 { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
export default function IntroPage() {
|
export default function IntroPage() {
|
||||||
const router = useRouter();
|
const { showIntro } = useIntroSheet();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default function IntroPage() {
|
|||||||
<ListGroup title={t("home.settings.intro.title")}>
|
<ListGroup title={t("home.settings.intro.title")}>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/intro/page");
|
showIntro();
|
||||||
}}
|
}}
|
||||||
title={t("home.settings.intro.show_intro")}
|
title={t("home.settings.intro.show_intro")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
|
import {
|
||||||
|
clearCache,
|
||||||
|
clearPermanentDownloads,
|
||||||
|
getStorageStats,
|
||||||
|
} from "@/providers/AudioStorage";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
const CACHE_SIZE_OPTIONS = [
|
const CACHE_SIZE_OPTIONS = [
|
||||||
@@ -29,6 +38,40 @@ export default function MusicSettingsPage() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
|
const { data: musicCacheStats } = useQuery({
|
||||||
|
queryKey: ["musicCacheStats"],
|
||||||
|
queryFn: () => getStorageStats(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClearMusicCacheClicked = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await clearCache();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||||
|
successHapticFeedback();
|
||||||
|
toast.success(t("home.settings.storage.music_cache_cleared"));
|
||||||
|
} catch (_e) {
|
||||||
|
errorHapticFeedback();
|
||||||
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
|
}
|
||||||
|
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
|
||||||
|
|
||||||
|
const onDeleteDownloadedSongsClicked = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await clearPermanentDownloads();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||||
|
successHapticFeedback();
|
||||||
|
toast.success(t("home.settings.storage.downloaded_songs_deleted"));
|
||||||
|
} catch (_e) {
|
||||||
|
errorHapticFeedback();
|
||||||
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
|
}
|
||||||
|
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
|
||||||
|
|
||||||
const cacheSizeOptions = useMemo(
|
const cacheSizeOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -171,6 +214,37 @@ export default function MusicSettingsPage() {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<View className='mt-4'>
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.storage.music_cache_title")}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{t("home.settings.storage.music_cache_description")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
onPress={onClearMusicCacheClicked}
|
||||||
|
title={t("home.settings.storage.clear_music_cache")}
|
||||||
|
subtitle={t("home.settings.storage.music_cache_size", {
|
||||||
|
size: (musicCacheStats?.cacheSize ?? 0).bytesToReadable(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
<ListGroup>
|
||||||
|
<ListItem
|
||||||
|
textColor='red'
|
||||||
|
onPress={onDeleteDownloadedSongsClicked}
|
||||||
|
title={t("home.settings.storage.delete_all_downloaded_songs")}
|
||||||
|
subtitle={t("home.settings.storage.downloaded_songs_size", {
|
||||||
|
size: (musicCacheStats?.permanentSize ?? 0).bytesToReadable(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
48
app/(auth)/(tabs)/(home)/settings/network/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { LocalNetworkSettings } from "@/components/settings/LocalNetworkSettings";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
export default function NetworkSettingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const remoteUrl = storage.getString("serverUrl");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<ListGroup title={t("home.settings.network.current_server")}>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.network.remote_url")}
|
||||||
|
subtitle={remoteUrl ?? t("home.settings.network.not_configured")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.network.active_url")}
|
||||||
|
subtitle={api?.basePath ?? t("home.settings.network.not_connected")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className='mt-4'>
|
||||||
|
<LocalNetworkSettings />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { GestureControls } from "@/components/settings/GestureControls";
|
import { GestureControls } from "@/components/settings/GestureControls";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
|
import { MpvBufferSettings } from "@/components/settings/MpvBufferSettings";
|
||||||
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
||||||
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export default function PlaybackControlsPage() {
|
|||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
<PlaybackControlsSettings />
|
<PlaybackControlsSettings />
|
||||||
|
<MpvBufferSettings />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -16,6 +15,7 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -26,7 +26,7 @@ export default function page() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,6 +14,7 @@ import { toast } from "sonner-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -28,7 +28,7 @@ export default function page() {
|
|||||||
pluginSettings,
|
pluginSettings,
|
||||||
refreshStreamyfinPluginSettings,
|
refreshStreamyfinPluginSettings,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
// Local state for all editable fields
|
// Local state for all editable fields
|
||||||
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
||||||
|
|||||||
@@ -15,14 +15,24 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { TVFilterButton } from "@/components/tv";
|
||||||
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -36,19 +46,29 @@ import {
|
|||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 16;
|
||||||
|
const TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
const { collectionId } = searchParams as { collectionId: string };
|
||||||
|
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
const [orientation, _setOrientation] = useState(
|
const [orientation, _setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
@@ -56,7 +76,7 @@ const page: React.FC = () => {
|
|||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection, isLoading: isCollectionLoading } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -71,6 +91,46 @@ const page: React.FC = () => {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TV Filter queries
|
||||||
|
const { data: tvGenreOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvYearOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Years || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvTagOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({ title: collection?.Name || "" });
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
setSortOrder([SortOrderOption.Ascending]);
|
setSortOrder([SortOrderOption.Ascending]);
|
||||||
@@ -87,6 +147,18 @@ const page: React.FC = () => {
|
|||||||
setSortBy([sortByOption]);
|
setSortBy([sortByOption]);
|
||||||
}, [navigation, collection]);
|
}, [navigation, collection]);
|
||||||
|
|
||||||
|
// Calculate columns for TV grid
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
|
||||||
|
}, [screenWidth, orientation]);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
@@ -98,7 +170,7 @@ const page: React.FC = () => {
|
|||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
limit: 18,
|
limit: Platform.isTV ? 36 : 18,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||||
sortBy: [sortBy[0]],
|
sortBy: [sortBy[0]],
|
||||||
@@ -123,6 +195,7 @@ const page: React.FC = () => {
|
|||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
collection,
|
collection,
|
||||||
|
collectionId,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -131,39 +204,40 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
queryKey: [
|
useInfiniteQuery({
|
||||||
"collection-items",
|
queryKey: [
|
||||||
collection,
|
"collection-items",
|
||||||
selectedGenres,
|
collectionId,
|
||||||
selectedYears,
|
selectedGenres,
|
||||||
selectedTags,
|
selectedYears,
|
||||||
sortBy,
|
selectedTags,
|
||||||
sortOrder,
|
sortBy,
|
||||||
],
|
sortOrder,
|
||||||
queryFn: fetchItems,
|
],
|
||||||
getNextPageParam: (lastPage, pages) => {
|
queryFn: fetchItems,
|
||||||
if (
|
getNextPageParam: (lastPage, pages) => {
|
||||||
!lastPage?.Items ||
|
if (
|
||||||
!lastPage?.TotalRecordCount ||
|
!lastPage?.Items ||
|
||||||
lastPage?.TotalRecordCount === 0
|
!lastPage?.TotalRecordCount ||
|
||||||
)
|
lastPage?.TotalRecordCount === 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
|
const accumulatedItems = pages.reduce(
|
||||||
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accumulatedItems < totalItems) {
|
||||||
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
},
|
||||||
const totalItems = lastPage.TotalRecordCount;
|
initialPageParam: 0,
|
||||||
const accumulatedItems = pages.reduce(
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
});
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
|
||||||
return lastPage?.Items?.length * pages.length;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -195,7 +269,6 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
{/* <MoviePoster item={item} /> */}
|
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
@@ -203,9 +276,34 @@ const page: React.FC = () => {
|
|||||||
[orientation],
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const renderTVItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navTarget = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
const _insets = useSafeAreaInsets();
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginRight: TV_ITEM_GAP,
|
||||||
|
marginBottom: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVPosterCard
|
||||||
|
item={item}
|
||||||
|
orientation='vertical'
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={() => showItemActions(item)}
|
||||||
|
width={posterSizes.poster}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router, showItemActions, posterSizes.poster],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
@@ -372,48 +470,315 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TV Filter options - with "All" option for clearable filters
|
||||||
|
const tvGenreFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedGenres.length === 0,
|
||||||
|
},
|
||||||
|
...(tvGenreOptions || []).map((genre) => ({
|
||||||
|
label: genre,
|
||||||
|
value: genre,
|
||||||
|
selected: selectedGenres.includes(genre),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvGenreOptions, selectedGenres, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvYearFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedYears.length === 0,
|
||||||
|
},
|
||||||
|
...(tvYearOptions || []).map((year) => ({
|
||||||
|
label: String(year),
|
||||||
|
value: String(year),
|
||||||
|
selected: selectedYears.includes(String(year)),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvYearOptions, selectedYears, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvTagFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedTags.length === 0,
|
||||||
|
},
|
||||||
|
...(tvTagOptions || []).map((tag) => ({
|
||||||
|
label: tag,
|
||||||
|
value: tag,
|
||||||
|
selected: selectedTags.includes(tag),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvTagOptions, selectedTags, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortByOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortByOption>[] =>
|
||||||
|
sortOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortBy[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortOrderOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortOrderOption>[] =>
|
||||||
|
sortOrderOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortOrder[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV Filter handlers using navigation-based modal
|
||||||
|
const handleShowGenreFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.genres"),
|
||||||
|
options: tvGenreFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
} else if (selectedGenres.includes(value)) {
|
||||||
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
|
const handleShowYearFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.years"),
|
||||||
|
options: tvYearFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedYears([]);
|
||||||
|
} else if (selectedYears.includes(value)) {
|
||||||
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedYears([...selectedYears, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
|
const handleShowTagFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.tags"),
|
||||||
|
options: tvTagFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedTags([]);
|
||||||
|
} else if (selectedTags.includes(value)) {
|
||||||
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedTags([...selectedTags, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_by"),
|
||||||
|
options: tvSortByOptions,
|
||||||
|
onSelect: (value: SortByOption) => {
|
||||||
|
setSortBy([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||||
|
|
||||||
|
const handleShowSortOrderFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_order"),
|
||||||
|
options: tvSortOrderOptions,
|
||||||
|
onSelect: (value: SortOrderOption) => {
|
||||||
|
setSortOrder([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||||
|
|
||||||
|
// TV filter bar state
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
|
||||||
|
|
||||||
|
if (isLoading || isCollectionLoading) {
|
||||||
|
return (
|
||||||
|
<View className='w-full h-full flex items-center justify-center'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
// Mobile return
|
||||||
<FlashList
|
if (!Platform.isTV) {
|
||||||
ListEmptyComponent={
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<FlashList
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
ListEmptyComponent={
|
||||||
{t("search.no_results")}
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
</View>
|
{t("search.no_results")}
|
||||||
}
|
</Text>
|
||||||
extraData={[
|
</View>
|
||||||
selectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
selectedTags,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
]}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
data={flatData}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
numColumns={
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
}
|
||||||
}}
|
extraData={[
|
||||||
onEndReachedThreshold={0.5}
|
selectedGenres,
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
selectedYears,
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
selectedTags,
|
||||||
ItemSeparatorComponent={() => (
|
sortBy,
|
||||||
<View
|
sortOrder,
|
||||||
style={{
|
]}
|
||||||
width: 10,
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
height: 10,
|
data={flatData}
|
||||||
}}
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV return with filter bar
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Filter bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
marginTop: insets.top + 100,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingHorizontal: TV_SCALE_PADDING,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowGenreFilter}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
/>
|
/>
|
||||||
)}
|
<TVFilterButton
|
||||||
/>
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowYearFilter}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowTagFilter}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={handleShowSortByFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={handleShowSortOrderFilter}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<FlatList
|
||||||
|
key={`${orientation}-${nrOfCols}`}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("search.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: TV_SCALE_PADDING,
|
||||||
|
paddingRight: TV_SCALE_PADDING,
|
||||||
|
paddingTop: 20,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
@@ -13,6 +12,11 @@ import Animated, {
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
|
const ItemContentSkeletonTV = Platform.isTV
|
||||||
|
? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV
|
||||||
|
: null;
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
@@ -23,7 +27,11 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
||||||
// (especially important for plugins like Gelato)
|
// (especially important for plugins like Gelato)
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
|
const {
|
||||||
|
data: item,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
} = useItemQuery(id, isOffline, undefined, [
|
||||||
ItemFields.MediaSources,
|
ItemFields.MediaSources,
|
||||||
ItemFields.MediaSourceCount,
|
ItemFields.MediaSourceCount,
|
||||||
ItemFields.MediaStreams,
|
ItemFields.MediaStreams,
|
||||||
@@ -39,33 +47,14 @@ const Page: React.FC = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const fadeOut = (callback: any) => {
|
// Fast fade out when item loads (no setTimeout delay)
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fadeIn = (callback: any) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
fadeOut(() => {});
|
opacity.value = withTiming(0, { duration: 150 });
|
||||||
} else {
|
} else {
|
||||||
fadeIn(() => {});
|
opacity.value = withTiming(1, { duration: 150 });
|
||||||
}
|
}
|
||||||
}, [item]);
|
}, [item, opacity]);
|
||||||
|
|
||||||
if (isError)
|
if (isError)
|
||||||
return (
|
return (
|
||||||
@@ -75,39 +64,50 @@ const Page: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-1 relative'>
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
<Animated.View
|
<View className='flex flex-1 relative'>
|
||||||
pointerEvents={"none"}
|
{/* Always render ItemContent - it handles loading state internally on TV */}
|
||||||
style={[animatedStyle]}
|
|
||||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
|
||||||
}}
|
|
||||||
className='bg-transparent rounded-lg mb-4 w-full'
|
|
||||||
/>
|
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
|
||||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
|
||||||
<View className='flex flex-row space-x-1 mb-8'>
|
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
|
||||||
</View>
|
|
||||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
|
||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
|
||||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
|
||||||
</Animated.View>
|
|
||||||
{item && (
|
|
||||||
<ItemContent
|
<ItemContent
|
||||||
item={item}
|
item={item}
|
||||||
isOffline={isOffline}
|
|
||||||
itemWithSources={itemWithSources}
|
itemWithSources={itemWithSources}
|
||||||
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</View>
|
{/* Skeleton overlay - fades out when content loads */}
|
||||||
|
{!item && (
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents={"none"}
|
||||||
|
style={[animatedStyle]}
|
||||||
|
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
|
||||||
|
>
|
||||||
|
{Platform.isTV && ItemContentSkeletonTV ? (
|
||||||
|
<ItemContentSkeletonTV />
|
||||||
|
) : (
|
||||||
|
<View style={{ paddingHorizontal: 16, width: "100%" }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 450,
|
||||||
|
}}
|
||||||
|
className='bg-transparent rounded-lg mb-4 w-full'
|
||||||
|
/>
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||||
|
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||||
|
<View className='flex flex-row space-x-1 mb-8'>
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
</View>
|
||||||
|
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -21,12 +21,14 @@ import { GenreTags } from "@/components/GenreTags";
|
|||||||
import Cast from "@/components/jellyseerr/Cast";
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
|
import { TVJellyseerrPage } from "@/components/jellyseerr/tv";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
@@ -51,7 +53,8 @@ import type {
|
|||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
// Mobile page component
|
||||||
|
const MobilePage: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -541,4 +544,12 @@ const Page: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Platform-conditional page component
|
||||||
|
const Page: React.FC = () => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVJellyseerrPage />;
|
||||||
|
}
|
||||||
|
return <MobilePage />;
|
||||||
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type {
|
|||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { Stack, withLayoutContext } from "expo-router";
|
import { Slot, Stack, withLayoutContext } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
@@ -19,6 +20,17 @@ export const Tab = withLayoutContext<
|
|||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
|
// On TV, skip the Material Top Tab Navigator and render children directly
|
||||||
|
// The TV version handles its own tab navigation internally
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<Slot />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Live TV" }} />
|
<Stack.Screen options={{ title: "Live TV" }} />
|
||||||
|
|||||||
@@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVLiveTVPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MobileLiveTVPrograms />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileLiveTVPrograms() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export default function ArtistDetailScreen() {
|
|||||||
{section.type === "albums" ? (
|
{section.type === "albums" ? (
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={section.data}
|
data={section.data}
|
||||||
height={200}
|
height={178}
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
Dimensions,
|
|
||||||
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 { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -30,8 +25,7 @@ import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
|||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
const ARTWORK_SIZE = 120;
|
||||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
|
||||||
|
|
||||||
export default function PlaylistDetailScreen() {
|
export default function PlaylistDetailScreen() {
|
||||||
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
|
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -15,6 +15,7 @@ import { Loader } from "@/components/Loader";
|
|||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { TVActorPage } from "@/components/persons/TVActorPage";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
@@ -23,6 +24,16 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
|
// Render TV-optimized page on TV platforms
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVActorPage personId={personId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MobileActorPage personId={personId} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MobileActorPage: React.FC<{ personId: string }> = ({ personId }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|||||||
@@ -14,86 +14,127 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
|
import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
import {
|
||||||
|
buildOfflineSeriesFromEpisodes,
|
||||||
|
getDownloadedEpisodesForSeries,
|
||||||
|
} from "@/utils/downloads/offline-series";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
const {
|
||||||
|
id: seriesId,
|
||||||
|
seasonIndex,
|
||||||
|
offline: offlineParam,
|
||||||
|
} = params as {
|
||||||
id: string;
|
id: string;
|
||||||
seasonIndex: string;
|
seasonIndex: string;
|
||||||
|
offline?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isOffline = offlineParam === "true";
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const { getDownloadedItems, downloadedItems } = useDownload();
|
||||||
|
|
||||||
|
// For offline mode, construct series data from downloaded episodes
|
||||||
|
// Include downloadedItems.length so query refetches when items are deleted
|
||||||
const { data: item } = useQuery({
|
const { data: item } = useQuery({
|
||||||
queryKey: ["series", seriesId],
|
queryKey: ["series", seriesId, isOffline, downloadedItems.length],
|
||||||
queryFn: async () =>
|
queryFn: async () => {
|
||||||
await getUserItemData({
|
if (isOffline) {
|
||||||
|
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
|
||||||
|
}
|
||||||
|
return await getUserItemData({
|
||||||
api,
|
api,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: seriesId,
|
itemId: seriesId,
|
||||||
}),
|
});
|
||||||
staleTime: 60 * 1000,
|
},
|
||||||
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
// For offline mode, use stored base64 image
|
||||||
() =>
|
const base64Image = useMemo(() => {
|
||||||
getBackdropUrl({
|
if (isOffline) {
|
||||||
api,
|
return storage.getString(seriesId);
|
||||||
item,
|
}
|
||||||
quality: 90,
|
return null;
|
||||||
width: 1000,
|
}, [isOffline, seriesId]);
|
||||||
}),
|
|
||||||
[item],
|
|
||||||
);
|
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const backdropUrl = useMemo(() => {
|
||||||
() =>
|
if (isOffline && base64Image) {
|
||||||
getLogoImageUrlById({
|
return `data:image/jpeg;base64,${base64Image}`;
|
||||||
api,
|
}
|
||||||
item,
|
return getBackdropUrl({
|
||||||
}),
|
api,
|
||||||
[item],
|
item,
|
||||||
);
|
quality: 90,
|
||||||
|
width: 1000,
|
||||||
|
});
|
||||||
|
}, [isOffline, base64Image, api, item]);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(() => {
|
||||||
|
if (isOffline) {
|
||||||
|
return null; // No logo in offline mode
|
||||||
|
}
|
||||||
|
return getLogoImageUrlById({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}, [isOffline, api, item]);
|
||||||
|
|
||||||
const { data: allEpisodes, isLoading } = useQuery({
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
queryKey: ["AllEpisodes", item?.Id],
|
queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !item?.Id) return [];
|
if (isOffline) {
|
||||||
|
return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
|
||||||
|
}
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
const res = await getTvShowsApi(api).getEpisodes({
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
seriesId: item.Id,
|
seriesId: seriesId,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
// Note: Including trick play is necessary to enable trick play downloads
|
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
});
|
});
|
||||||
return res?.data.Items || [];
|
return res?.data.Items || [];
|
||||||
},
|
},
|
||||||
select: (data) =>
|
select: (data) =>
|
||||||
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
|
||||||
[...(data || [])].sort(
|
[...(data || [])].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
),
|
),
|
||||||
staleTime: 60,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Don't show header buttons in offline mode
|
||||||
|
if (isOffline) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
!isLoading &&
|
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||||
item &&
|
|
||||||
allEpisodes &&
|
|
||||||
allEpisodes.length > 0 && (
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
@@ -114,49 +155,77 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
) : null,
|
||||||
});
|
});
|
||||||
}, [allEpisodes, isLoading, item]);
|
}, [allEpisodes, isLoading, item, isOffline]);
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
// For offline mode, we can show the page even without backdropUrl
|
||||||
|
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||||
|
|
||||||
|
// TV version
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
|
<TVSeriesPage
|
||||||
|
item={item}
|
||||||
|
allEpisodes={allEpisodes}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</OfflineModeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxScrollView
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
headerHeight={400}
|
<ParallaxScrollView
|
||||||
headerImage={
|
headerHeight={400}
|
||||||
<Image
|
headerImage={
|
||||||
source={{
|
backdropUrl ? (
|
||||||
uri: backdropUrl,
|
<Image
|
||||||
}}
|
source={{
|
||||||
style={{
|
uri: backdropUrl,
|
||||||
width: "100%",
|
}}
|
||||||
height: "100%",
|
style={{
|
||||||
}}
|
width: "100%",
|
||||||
/>
|
height: "100%",
|
||||||
}
|
}}
|
||||||
logo={
|
/>
|
||||||
logoUrl ? (
|
) : (
|
||||||
<Image
|
<View
|
||||||
source={{
|
style={{
|
||||||
uri: logoUrl,
|
width: "100%",
|
||||||
}}
|
height: "100%",
|
||||||
style={{
|
backgroundColor: "#1a1a1a",
|
||||||
height: 130,
|
}}
|
||||||
width: "100%",
|
/>
|
||||||
}}
|
)
|
||||||
contentFit='contain'
|
}
|
||||||
/>
|
logo={
|
||||||
) : undefined
|
logoUrl ? (
|
||||||
}
|
<Image
|
||||||
>
|
source={{
|
||||||
<View className='flex flex-col pt-4'>
|
uri: logoUrl,
|
||||||
<SeriesHeader item={item} />
|
}}
|
||||||
<View className='mb-4'>
|
style={{
|
||||||
<NextUp seriesId={seriesId} />
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col pt-4'>
|
||||||
|
<SeriesHeader item={item} />
|
||||||
|
{!isOffline && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<NextUp seriesId={seriesId} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||||
</View>
|
</View>
|
||||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
</ParallaxScrollView>
|
||||||
</View>
|
</OfflineModeProvider>
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,20 +11,37 @@ 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";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import {
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { TVFilterButton, TVFocusablePoster } from "@/components/tv";
|
||||||
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -48,11 +65,25 @@ import {
|
|||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 20;
|
||||||
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
const _TV_SCALE_PADDING = 20;
|
||||||
|
const TV_PLAYLIST_SQUARE_SIZE = 180;
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams() as {
|
||||||
const { libraryId } = searchParams as { libraryId: string };
|
libraryId: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
|
filterBy?: string;
|
||||||
|
};
|
||||||
|
const { libraryId } = searchParams;
|
||||||
|
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
@@ -74,25 +105,78 @@ const Page = () => {
|
|||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
|
// TV Filter queries
|
||||||
|
const { data: tvGenreOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvYearOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Years", "tvYearFilter", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Years || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvTagOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Tags", "tvTagFilter", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
// Check for URL params first (from "See All" navigation)
|
||||||
if (sop) {
|
const urlSortBy = searchParams.sortBy as SortByOption | undefined;
|
||||||
_setSortOrder([sop]);
|
const urlSortOrder = searchParams.sortOrder as SortOrderOption | undefined;
|
||||||
|
const urlFilterBy = searchParams.filterBy as FilterByOption | undefined;
|
||||||
|
|
||||||
|
// Apply sortOrder: URL param > saved preference > default
|
||||||
|
if (urlSortOrder && Object.values(SortOrderOption).includes(urlSortOrder)) {
|
||||||
|
_setSortOrder([urlSortOrder]);
|
||||||
} else {
|
} else {
|
||||||
_setSortOrder([SortOrderOption.Ascending]);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
|
_setSortOrder([sop || SortOrderOption.Ascending]);
|
||||||
}
|
}
|
||||||
const obp = getSortByPreference(libraryId, sortByPreference);
|
|
||||||
if (obp) {
|
// Apply sortBy: URL param > saved preference > default
|
||||||
_setSortBy([obp]);
|
if (urlSortBy && Object.values(SortByOption).includes(urlSortBy)) {
|
||||||
|
_setSortBy([urlSortBy]);
|
||||||
} else {
|
} else {
|
||||||
_setSortBy([SortByOption.SortName]);
|
const obp = getSortByPreference(libraryId, sortByPreference);
|
||||||
|
_setSortBy([obp || SortByOption.SortName]);
|
||||||
}
|
}
|
||||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
|
||||||
if (fp) {
|
// Apply filterBy: URL param > saved preference > default
|
||||||
_setFilterBy([fp]);
|
if (urlFilterBy && Object.values(FilterByOption).includes(urlFilterBy)) {
|
||||||
|
_setFilterBy([urlFilterBy]);
|
||||||
} else {
|
} else {
|
||||||
_setFilterBy([]);
|
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||||
|
_setFilterBy(fp ? [fp] : []);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
libraryId,
|
libraryId,
|
||||||
@@ -102,6 +186,9 @@ const Page = () => {
|
|||||||
_setSortBy,
|
_setSortBy,
|
||||||
filterByPreference,
|
filterByPreference,
|
||||||
_setFilterBy,
|
_setFilterBy,
|
||||||
|
searchParams.sortBy,
|
||||||
|
searchParams.sortOrder,
|
||||||
|
searchParams.filterBy,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const setSortBy = useCallback(
|
const setSortBy = useCallback(
|
||||||
@@ -144,6 +231,10 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -191,6 +282,12 @@ const Page = () => {
|
|||||||
itemType = "Series";
|
itemType = "Series";
|
||||||
} else if (library.CollectionType === "boxsets") {
|
} else if (library.CollectionType === "boxsets") {
|
||||||
itemType = "BoxSet";
|
itemType = "BoxSet";
|
||||||
|
} else if (library.CollectionType === "homevideos") {
|
||||||
|
itemType = "Video";
|
||||||
|
} else if (library.CollectionType === "musicvideos") {
|
||||||
|
itemType = "MusicVideo";
|
||||||
|
} else if (library.CollectionType === "playlists") {
|
||||||
|
itemType = "Playlist";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
@@ -210,6 +307,9 @@ const Page = () => {
|
|||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||||
includeItemTypes: itemType ? [itemType] : undefined,
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
|
...(Platform.isTV && library.CollectionType === "playlists"
|
||||||
|
? { mediaTypes: ["Video"] }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -300,7 +400,88 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation],
|
[orientation, nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
if (item.Type === "Playlist") {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||||
|
params: { libraryId: item.Id! },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const navTarget = getItemNavigation(item, "(libraries)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Special rendering for Playlist items (square thumbnails)
|
||||||
|
if (item.Type === "Playlist") {
|
||||||
|
const playlistImageUrl = getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
width: TV_PLAYLIST_SQUARE_SIZE * 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.Id}
|
||||||
|
style={{
|
||||||
|
width: TV_PLAYLIST_SQUARE_SIZE,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={() => showItemActions(item)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_PLAYLIST_SQUARE_SIZE,
|
||||||
|
aspectRatio: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={playlistImageUrl ? { uri: playlistImageUrl } : null}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<View style={{ marginTop: 12, alignItems: "center" }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TVPosterCard
|
||||||
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
|
orientation='vertical'
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={() => showItemActions(item)}
|
||||||
|
width={posterSizes.poster}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router, showItemActions, api, typography],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
@@ -487,6 +668,188 @@ const Page = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TV Filter bar header
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0 ||
|
||||||
|
filterBy.length > 0;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
_setFilterBy([]);
|
||||||
|
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
|
||||||
|
|
||||||
|
// TV Filter options - with "All" option for clearable filters
|
||||||
|
const tvGenreFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedGenres.length === 0,
|
||||||
|
},
|
||||||
|
...(tvGenreOptions || []).map((genre) => ({
|
||||||
|
label: genre,
|
||||||
|
value: genre,
|
||||||
|
selected: selectedGenres.includes(genre),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvGenreOptions, selectedGenres, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvYearFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedYears.length === 0,
|
||||||
|
},
|
||||||
|
...(tvYearOptions || []).map((year) => ({
|
||||||
|
label: String(year),
|
||||||
|
value: String(year),
|
||||||
|
selected: selectedYears.includes(String(year)),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvYearOptions, selectedYears, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvTagFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedTags.length === 0,
|
||||||
|
},
|
||||||
|
...(tvTagOptions || []).map((tag) => ({
|
||||||
|
label: tag,
|
||||||
|
value: tag,
|
||||||
|
selected: selectedTags.includes(tag),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvTagOptions, selectedTags, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortByOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortByOption>[] =>
|
||||||
|
sortOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortBy[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortOrderOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortOrderOption>[] =>
|
||||||
|
sortOrderOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortOrder[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvFilterByOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: filterBy.length === 0,
|
||||||
|
},
|
||||||
|
...generalFilters.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: filterBy.includes(option.key),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[filterBy, generalFilters, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV Filter handlers using navigation-based modal
|
||||||
|
const handleShowGenreFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.genres"),
|
||||||
|
options: tvGenreFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
} else if (selectedGenres.includes(value)) {
|
||||||
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
|
const handleShowYearFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.years"),
|
||||||
|
options: tvYearFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedYears([]);
|
||||||
|
} else if (selectedYears.includes(value)) {
|
||||||
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedYears([...selectedYears, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
|
const handleShowTagFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.tags"),
|
||||||
|
options: tvTagFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedTags([]);
|
||||||
|
} else if (selectedTags.includes(value)) {
|
||||||
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedTags([...selectedTags, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_by"),
|
||||||
|
options: tvSortByOptions,
|
||||||
|
onSelect: (value: SortByOption) => {
|
||||||
|
setSortBy([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||||
|
|
||||||
|
const handleShowSortOrderFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_order"),
|
||||||
|
options: tvSortOrderOptions,
|
||||||
|
onSelect: (value: SortOrderOption) => {
|
||||||
|
setSortOrder([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||||
|
|
||||||
|
const handleShowFilterByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.filter_by"),
|
||||||
|
options: tvFilterByOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
_setFilterBy([]);
|
||||||
|
} else {
|
||||||
|
setFilter([value as FilterByOption]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading || isLibraryLoading)
|
if (isLoading || isLibraryLoading)
|
||||||
@@ -496,43 +859,176 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mobile return
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
key={orientation}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV return with filter bar
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<ScrollView
|
||||||
key={orientation}
|
style={{ flex: 1 }}
|
||||||
ListEmptyComponent={
|
contentContainerStyle={{
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
paddingTop: insets.top + 100,
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
paddingBottom: insets.bottom + 60,
|
||||||
{t("library.no_results")}
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
</Text>
|
}}
|
||||||
</View>
|
onScroll={({ nativeEvent }) => {
|
||||||
}
|
// Load more when near bottom
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||||
data={flatData}
|
const isNearBottom =
|
||||||
renderItem={renderItem}
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
extraData={[orientation, nrOfCols]}
|
contentSize.height - 500;
|
||||||
keyExtractor={keyExtractor}
|
if (isNearBottom && hasNextPage && !isFetching) {
|
||||||
numColumns={nrOfCols}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onEndReachedThreshold={1}
|
scrollEventThrottle={400}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
>
|
||||||
contentContainerStyle={{
|
{/* Filter bar */}
|
||||||
paddingBottom: 24,
|
<View
|
||||||
paddingLeft: insets.left,
|
style={{
|
||||||
paddingRight: insets.right,
|
flexDirection: "row",
|
||||||
}}
|
flexWrap: "nowrap",
|
||||||
ItemSeparatorComponent={() => (
|
justifyContent: "center",
|
||||||
|
paddingBottom: 24,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowGenreFilter}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowYearFilter}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowTagFilter}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={handleShowSortByFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={handleShowSortOrderFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.filter_by")}
|
||||||
|
value={
|
||||||
|
filterBy.length > 0
|
||||||
|
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowFilterByFilter}
|
||||||
|
hasActiveFilter={filterBy.length > 0}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid with flexWrap */}
|
||||||
|
{flatData.length === 0 ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
flex: 1,
|
||||||
height: 10,
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 100,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||||
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flatData.map((item) => renderTVItem(item))}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isFetching && (
|
||||||
|
<View style={{ paddingVertical: 20 }}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,109 +1,11 @@
|
|||||||
import {
|
import { Platform } from "react-native";
|
||||||
getUserLibraryApi,
|
import { Libraries } from "@/components/library/Libraries";
|
||||||
getUserViewsApi,
|
import { TVLibraries } from "@/components/library/TVLibraries";
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, StyleSheet, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function index() {
|
export default function LibrariesPage() {
|
||||||
const [api] = useAtom(apiAtom);
|
if (Platform.isTV) {
|
||||||
const [user] = useAtom(userAtom);
|
return <TVLibraries />;
|
||||||
const queryClient = useQueryClient();
|
}
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
return <Libraries />;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ["user-views", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getUserViewsApi(api!).getUserViews({
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
staleTime: 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
const libraries = useMemo(
|
|
||||||
() =>
|
|
||||||
data
|
|
||||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
|
||||||
.filter((l) => l.CollectionType !== "books") || [],
|
|
||||||
[data, settings?.hiddenLibraries],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (const item of data || []) {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["library", item.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!item.Id || !user?.Id || !api) return null;
|
|
||||||
const response = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId: item.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<View className='justify-center items-center h-full'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!libraries)
|
|
||||||
return (
|
|
||||||
<View className='h-full w-full flex justify-center items-center'>
|
|
||||||
<Text className='text-lg text-neutral-500'>
|
|
||||||
{t("library.no_libraries_found")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlashList
|
|
||||||
extraData={settings}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
|
||||||
paddingBottom: 150,
|
|
||||||
paddingLeft: insets.left + 17,
|
|
||||||
paddingRight: insets.right + 17,
|
|
||||||
}}
|
|
||||||
data={libraries}
|
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
|
||||||
keyExtractor={(item) => item.Id || ""}
|
|
||||||
ItemSeparatorComponent={() =>
|
|
||||||
settings?.libraryOptions?.display === "row" ? (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='bg-neutral-800 mx-2 my-4'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className='h-4' />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
const TAB_LABEL_FONT_SIZE = 13;
|
const TAB_LABEL_FONT_SIZE = 13;
|
||||||
const TAB_ITEM_HORIZONTAL_PADDING = 18;
|
const TAB_ITEM_HORIZONTAL_PADDING = 12;
|
||||||
const TAB_ITEM_MIN_WIDTH = 110;
|
|
||||||
|
|
||||||
export const Tab = withLayoutContext<
|
export const Tab = withLayoutContext<
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
@@ -48,7 +47,6 @@ const Layout = () => {
|
|||||||
},
|
},
|
||||||
tabBarItemStyle: {
|
tabBarItemStyle: {
|
||||||
width: "auto",
|
width: "auto",
|
||||||
minWidth: TAB_ITEM_MIN_WIDTH,
|
|
||||||
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
||||||
},
|
},
|
||||||
tabBarStyle: { backgroundColor: "black" },
|
tabBarStyle: { backgroundColor: "black" },
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dimensions, RefreshControl, View } from "react-native";
|
import { RefreshControl, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 40;
|
const ITEMS_PER_PAGE = 40;
|
||||||
@@ -65,13 +65,6 @@ export default function AlbumsScreen() {
|
|||||||
return data?.pages.flatMap((page) => page.items) || [];
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const numColumns = 2;
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const gap = 12;
|
|
||||||
const padding = 16;
|
|
||||||
const itemWidth =
|
|
||||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
const handleEndReached = useCallback(() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
@@ -98,11 +91,10 @@ export default function AlbumsScreen() {
|
|||||||
<View className='flex-1 bg-black'>
|
<View className='flex-1 bg-black'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={albums}
|
data={albums}
|
||||||
numColumns={numColumns}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: insets.bottom + 100,
|
paddingBottom: insets.bottom + 100,
|
||||||
paddingTop: 16,
|
paddingTop: 8,
|
||||||
paddingHorizontal: padding,
|
paddingHorizontal: 16,
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -113,17 +105,7 @@ export default function AlbumsScreen() {
|
|||||||
}
|
}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item }) => <MusicAlbumRowCard album={item} />}
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: itemWidth,
|
|
||||||
marginRight: index % numColumns === 0 ? gap : 0,
|
|
||||||
marginBottom: gap,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MusicAlbumCard album={item} width={itemWidth} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
isFetchingNextPage ? (
|
isFetchingNextPage ? (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dimensions, RefreshControl, View } from "react-native";
|
import { RefreshControl, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -71,13 +71,6 @@ export default function ArtistsScreen() {
|
|||||||
return data?.pages.flatMap((page) => page.items) || [];
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const numColumns = 3;
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const gap = 12;
|
|
||||||
const padding = 16;
|
|
||||||
const itemWidth =
|
|
||||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
const handleEndReached = useCallback(() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
@@ -135,11 +128,10 @@ export default function ArtistsScreen() {
|
|||||||
<View className='flex-1 bg-black'>
|
<View className='flex-1 bg-black'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={artists}
|
data={artists}
|
||||||
numColumns={numColumns}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: insets.bottom + 100,
|
paddingBottom: insets.bottom + 100,
|
||||||
paddingTop: 16,
|
paddingTop: 8,
|
||||||
paddingHorizontal: padding,
|
paddingHorizontal: 16,
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -150,17 +142,7 @@ export default function ArtistsScreen() {
|
|||||||
}
|
}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item }) => <MusicArtistCard artist={item} />}
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: itemWidth,
|
|
||||||
marginRight: index % numColumns !== numColumns - 1 ? gap : 0,
|
|
||||||
marginBottom: gap,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MusicArtistCard artist={item} size={itemWidth} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
isFetchingNextPage ? (
|
isFetchingNextPage ? (
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
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";
|
||||||
import {
|
import { RefreshControl, TouchableOpacity, View } from "react-native";
|
||||||
Dimensions,
|
|
||||||
RefreshControl,
|
|
||||||
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 { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
||||||
|
import {
|
||||||
|
type PlaylistSortOption,
|
||||||
|
type PlaylistSortOrder,
|
||||||
|
PlaylistSortSheet,
|
||||||
|
} from "@/components/music/PlaylistSortSheet";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 40;
|
const ITEMS_PER_PAGE = 40;
|
||||||
@@ -36,9 +36,20 @@ export default function PlaylistsScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [sortSheetOpen, setSortSheetOpen] = useState(false);
|
||||||
|
const [sortBy, setSortBy] = useState<PlaylistSortOption>("SortName");
|
||||||
|
const [sortOrder, setSortOrder] = useState<PlaylistSortOrder>("Ascending");
|
||||||
|
|
||||||
const isReady = Boolean(api && user?.Id && libraryId);
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
const handleSortChange = useCallback(
|
||||||
|
(newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
|
||||||
|
setSortBy(newSortBy);
|
||||||
|
setSortOrder(newSortOrder);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
@@ -63,13 +74,13 @@ export default function PlaylistsScreen() {
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
refetch,
|
refetch,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: ["music-playlists", libraryId, user?.Id],
|
queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder],
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
const response = await getItemsApi(api!).getItems({
|
const response = await getItemsApi(api!).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
includeItemTypes: ["Playlist"],
|
includeItemTypes: ["Playlist"],
|
||||||
sortBy: ["SortName"],
|
sortBy: [sortBy],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: [sortOrder],
|
||||||
limit: ITEMS_PER_PAGE,
|
limit: ITEMS_PER_PAGE,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
@@ -93,13 +104,6 @@ export default function PlaylistsScreen() {
|
|||||||
return data?.pages.flatMap((page) => page.items) || [];
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const numColumns = 2;
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const gap = 12;
|
|
||||||
const padding = 16;
|
|
||||||
const itemWidth =
|
|
||||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
const handleEndReached = useCallback(() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
@@ -171,11 +175,10 @@ export default function PlaylistsScreen() {
|
|||||||
<View className='flex-1 bg-black'>
|
<View className='flex-1 bg-black'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={playlists}
|
data={playlists}
|
||||||
numColumns={numColumns}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: insets.bottom + 100,
|
paddingBottom: insets.bottom + 100,
|
||||||
paddingTop: 16,
|
paddingTop: 8,
|
||||||
paddingHorizontal: padding,
|
paddingHorizontal: 16,
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -186,17 +189,26 @@ export default function PlaylistsScreen() {
|
|||||||
}
|
}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
renderItem={({ item, index }) => (
|
ListHeaderComponent={
|
||||||
<View
|
<TouchableOpacity
|
||||||
style={{
|
onPress={() => setSortSheetOpen(true)}
|
||||||
width: itemWidth,
|
className='flex-row items-center mb-2 py-1'
|
||||||
marginRight: index % numColumns === 0 ? gap : 0,
|
|
||||||
marginBottom: gap,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MusicPlaylistCard playlist={item} width={itemWidth} />
|
<Ionicons name='swap-vertical' size={18} color='#9334E9' />
|
||||||
</View>
|
<Text className='text-purple-500 text-sm ml-1.5'>
|
||||||
)}
|
{t(
|
||||||
|
`music.sort.${sortBy === "SortName" ? "alphabetical" : "date_created"}`,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={sortOrder === "Ascending" ? "arrow-up" : "arrow-down"}
|
||||||
|
size={14}
|
||||||
|
color='#9334E9'
|
||||||
|
style={{ marginLeft: 4 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => <MusicPlaylistCard playlist={item} />}
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
isFetchingNextPage ? (
|
isFetchingNextPage ? (
|
||||||
@@ -210,6 +222,13 @@ export default function PlaylistsScreen() {
|
|||||||
open={createModalOpen}
|
open={createModalOpen}
|
||||||
setOpen={setCreateModalOpen}
|
setOpen={setCreateModalOpen}
|
||||||
/>
|
/>
|
||||||
|
<PlaylistSortSheet
|
||||||
|
open={sortSheetOpen}
|
||||||
|
setOpen={setSortSheetOpen}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export default function SuggestionsScreen() {
|
|||||||
{section.type === "albums" ? (
|
{section.type === "albums" ? (
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={section.data}
|
data={section.data}
|
||||||
height={200}
|
height={178}
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import type {
|
|||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
import { orderBy, uniqBy } from "lodash";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -19,11 +22,12 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
@@ -35,10 +39,20 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
|||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
|
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { 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";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
PersonResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { createStreamystatsApi } from "@/utils/streamystats";
|
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
@@ -55,6 +69,10 @@ const exampleSearches = [
|
|||||||
export default function search() {
|
export default function search() {
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(search)";
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -68,7 +86,23 @@ export default function search() {
|
|||||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
const [debouncedSearch] = useDebounce(search, 500);
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const searchDebouncer = useAsyncDebouncer(
|
||||||
|
async (query: string) => {
|
||||||
|
// Cancel previous in-flight requests
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
setDebouncedSearch(query);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
{ wait: 200 },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
searchDebouncer.maybeExecute(search);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
@@ -98,9 +132,11 @@ export default function search() {
|
|||||||
async ({
|
async ({
|
||||||
types,
|
types,
|
||||||
query,
|
query,
|
||||||
|
signal,
|
||||||
}: {
|
}: {
|
||||||
types: BaseItemKind[];
|
types: BaseItemKind[];
|
||||||
query: string;
|
query: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<BaseItemDto[]> => {
|
}): Promise<BaseItemDto[]> => {
|
||||||
if (!api || !query) {
|
if (!api || !query) {
|
||||||
return [];
|
return [];
|
||||||
@@ -108,13 +144,16 @@ export default function search() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (searchEngine === "Jellyfin") {
|
if (searchEngine === "Jellyfin") {
|
||||||
const searchApi = await getItemsApi(api).getItems({
|
const searchApi = await getItemsApi(api).getItems(
|
||||||
searchTerm: query,
|
{
|
||||||
limit: 10,
|
searchTerm: query,
|
||||||
includeItemTypes: types,
|
limit: 10,
|
||||||
recursive: true,
|
includeItemTypes: types,
|
||||||
userId: user?.Id,
|
recursive: true,
|
||||||
});
|
userId: user?.Id,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||||
}
|
}
|
||||||
@@ -143,6 +182,7 @@ export default function search() {
|
|||||||
query,
|
query,
|
||||||
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
||||||
10,
|
10,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
const allIds: string[] = [
|
const allIds: string[] = [
|
||||||
@@ -157,10 +197,13 @@ export default function search() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsResponse = await getItemsApi(api).getItems({
|
const itemsResponse = await getItemsApi(api).getItems(
|
||||||
ids: allIds,
|
{
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
ids: allIds,
|
||||||
});
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
||||||
}
|
}
|
||||||
@@ -170,13 +213,11 @@ export default function search() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${
|
const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
settings.marlinServerUrl
|
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
|
||||||
.map((type) => encodeURIComponent(type))
|
.map((type) => encodeURIComponent(type))
|
||||||
.join("&includeItemTypes=")}`;
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
const response1 = await axios.get(url, { signal });
|
||||||
|
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
@@ -184,19 +225,65 @@ export default function search() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const response2 = await getItemsApi(api).getItems({
|
const response2 = await getItemsApi(api).getItems(
|
||||||
ids,
|
{
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
ids,
|
||||||
});
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
|
// Silently handle aborted requests
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings, user?.Id],
|
[api, searchEngine, settings, user?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Separate search function for music types - always uses Jellyfin since Streamystats doesn't support music
|
||||||
|
const jellyfinSearchFn = useCallback(
|
||||||
|
async ({
|
||||||
|
types,
|
||||||
|
query,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
types: BaseItemKind[];
|
||||||
|
query: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<BaseItemDto[]> => {
|
||||||
|
if (!api || !query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchApi = await getItemsApi(api).getItems(
|
||||||
|
{
|
||||||
|
searchTerm: query,
|
||||||
|
limit: 10,
|
||||||
|
includeItemTypes: types,
|
||||||
|
recursive: true,
|
||||||
|
userId: user?.Id,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||||
|
} catch (error) {
|
||||||
|
// Silently handle aborted requests
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
type HeaderSearchBarRef = {
|
type HeaderSearchBarRef = {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
blur: () => void;
|
blur: () => void;
|
||||||
@@ -243,6 +330,7 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Movie"],
|
types: ["Movie"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -253,6 +341,7 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Series"],
|
types: ["Series"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -263,6 +352,7 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Episode"],
|
types: ["Episode"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -273,6 +363,7 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["BoxSet"],
|
types: ["BoxSet"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -283,6 +374,52 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Person"],
|
types: ["Person"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
|
}),
|
||||||
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Music search queries - always use Jellyfin since Streamystats doesn't support music
|
||||||
|
const { data: artists, isFetching: l9 } = useQuery({
|
||||||
|
queryKey: ["search", "artists", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
jellyfinSearchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["MusicArtist"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
|
}),
|
||||||
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: albums, isFetching: l10 } = useQuery({
|
||||||
|
queryKey: ["search", "albums", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
jellyfinSearchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["MusicAlbum"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
|
}),
|
||||||
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: songs, isFetching: l11 } = useQuery({
|
||||||
|
queryKey: ["search", "songs", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
jellyfinSearchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["Audio"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
|
}),
|
||||||
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: playlists, isFetching: l12 } = useQuery({
|
||||||
|
queryKey: ["search", "playlists", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
jellyfinSearchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["Playlist"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -293,13 +430,201 @@ export default function search() {
|
|||||||
episodes?.length ||
|
episodes?.length ||
|
||||||
series?.length ||
|
series?.length ||
|
||||||
collections?.length ||
|
collections?.length ||
|
||||||
actors?.length
|
actors?.length ||
|
||||||
|
artists?.length ||
|
||||||
|
albums?.length ||
|
||||||
|
songs?.length ||
|
||||||
|
playlists?.length
|
||||||
);
|
);
|
||||||
}, [episodes, movies, series, collections, actors]);
|
}, [
|
||||||
|
episodes,
|
||||||
|
movies,
|
||||||
|
series,
|
||||||
|
collections,
|
||||||
|
actors,
|
||||||
|
artists,
|
||||||
|
albums,
|
||||||
|
songs,
|
||||||
|
playlists,
|
||||||
|
]);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return l1 || l2 || l3 || l7 || l8;
|
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
|
||||||
}, [l1, l2, l3, l7, l8]);
|
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
|
||||||
|
|
||||||
|
// TV item press handler
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jellyseerr search for TV
|
||||||
|
const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } =
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["search", "jellyseerr", "tv", debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = {
|
||||||
|
query: new URLSearchParams(debouncedSearch || "").toString(),
|
||||||
|
};
|
||||||
|
return await Promise.all([
|
||||||
|
jellyseerrApi?.search({ ...params, page: 1 }),
|
||||||
|
jellyseerrApi?.search({ ...params, page: 2 }),
|
||||||
|
jellyseerrApi?.search({ ...params, page: 3 }),
|
||||||
|
jellyseerrApi?.search({ ...params, page: 4 }),
|
||||||
|
]).then((all) =>
|
||||||
|
uniqBy(
|
||||||
|
all.flatMap((v) => v?.results || []),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
Platform.isTV &&
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process Jellyseerr results for TV
|
||||||
|
const jellyseerrMovieResults = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
jellyseerrTVResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.MOVIE,
|
||||||
|
) as MovieResult[],
|
||||||
|
[(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
[jellyseerrTVResults, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrTvResults = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
jellyseerrTVResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.TV,
|
||||||
|
) as TvResult[],
|
||||||
|
[(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
[jellyseerrTVResults, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrPersonResults = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
jellyseerrTVResults?.filter(
|
||||||
|
(r) => r.mediaType === "person",
|
||||||
|
) as PersonResult[],
|
||||||
|
[(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
[jellyseerrTVResults, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrTVNoResults = useMemo(() => {
|
||||||
|
return (
|
||||||
|
!jellyseerrMovieResults?.length &&
|
||||||
|
!jellyseerrTvResults?.length &&
|
||||||
|
!jellyseerrPersonResults?.length
|
||||||
|
);
|
||||||
|
}, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]);
|
||||||
|
|
||||||
|
// Fetch discover settings for TV (when no search query in Discover mode)
|
||||||
|
const { data: discoverSliders } = useQuery({
|
||||||
|
queryKey: ["search", "jellyseerr", "discoverSettings", "tv"],
|
||||||
|
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||||
|
enabled:
|
||||||
|
Platform.isTV &&
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TV Jellyseerr press handlers
|
||||||
|
const handleJellyseerrMoviePress = useCallback(
|
||||||
|
(item: MovieResult) => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
|
params: {
|
||||||
|
mediaTitle: item.title,
|
||||||
|
releaseYear: String(new Date(item.releaseDate || "").getFullYear()),
|
||||||
|
canRequest: "true",
|
||||||
|
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
|
||||||
|
mediaType: MediaType.MOVIE,
|
||||||
|
id: String(item.id),
|
||||||
|
backdropPath: item.backdropPath || "",
|
||||||
|
overview: item.overview || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router, jellyseerrApi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJellyseerrTvPress = useCallback(
|
||||||
|
(item: TvResult) => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
|
params: {
|
||||||
|
mediaTitle: item.name,
|
||||||
|
releaseYear: String(new Date(item.firstAirDate || "").getFullYear()),
|
||||||
|
canRequest: "true",
|
||||||
|
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
id: String(item.id),
|
||||||
|
backdropPath: item.backdropPath || "",
|
||||||
|
overview: item.overview || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router, jellyseerrApi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJellyseerrPersonPress = useCallback(
|
||||||
|
(item: PersonResult) => {
|
||||||
|
router.push(`/(auth)/jellyseerr/person/${item.id}` as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render TV search page
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<TVSearchPage
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
|
debouncedSearch={debouncedSearch}
|
||||||
|
movies={movies}
|
||||||
|
series={series}
|
||||||
|
episodes={episodes}
|
||||||
|
collections={collections}
|
||||||
|
actors={actors}
|
||||||
|
artists={artists}
|
||||||
|
albums={albums}
|
||||||
|
songs={songs}
|
||||||
|
playlists={playlists}
|
||||||
|
loading={loading}
|
||||||
|
noResults={noResults}
|
||||||
|
onItemPress={handleItemPress}
|
||||||
|
onItemLongPress={showItemActions}
|
||||||
|
searchType={searchType}
|
||||||
|
setSearchType={setSearchType}
|
||||||
|
showDiscover={!!jellyseerrApi}
|
||||||
|
jellyseerrMovies={jellyseerrMovieResults}
|
||||||
|
jellyseerrTv={jellyseerrTvResults}
|
||||||
|
jellyseerrPersons={jellyseerrPersonResults}
|
||||||
|
jellyseerrLoading={jellyseerrTVLoading}
|
||||||
|
jellyseerrNoResults={jellyseerrTVNoResults}
|
||||||
|
onJellyseerrMoviePress={handleJellyseerrMoviePress}
|
||||||
|
onJellyseerrTvPress={handleJellyseerrTvPress}
|
||||||
|
onJellyseerrPersonPress={handleJellyseerrPersonPress}
|
||||||
|
discoverSliders={discoverSliders}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -308,28 +633,9 @@ export default function search() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* <View
|
|
||||||
className='flex flex-col'
|
|
||||||
style={{
|
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
|
||||||
}}
|
|
||||||
> */}
|
|
||||||
{Platform.isTV && (
|
|
||||||
<Input
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
router.setParams({ q: "" });
|
|
||||||
setSearch(text);
|
|
||||||
}}
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
@@ -446,6 +752,172 @@ export default function search() {
|
|||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Music search results */}
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={artists}
|
||||||
|
header={t("search.artists")}
|
||||||
|
renderItem={(item: BaseItemDto) => {
|
||||||
|
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-24 mr-2 items-center'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Text className='text-xl'>👤</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={2} className='mt-2 text-center'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={albums}
|
||||||
|
header={t("search.albums")}
|
||||||
|
renderItem={(item: BaseItemDto) => {
|
||||||
|
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Text className='text-4xl'>🎵</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs' numberOfLines={1}>
|
||||||
|
{item.AlbumArtist || item.Artists?.join(", ")}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={songs}
|
||||||
|
header={t("search.songs")}
|
||||||
|
renderItem={(item: BaseItemDto) => {
|
||||||
|
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Text className='text-4xl'>🎵</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs' numberOfLines={1}>
|
||||||
|
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={playlists}
|
||||||
|
header={t("search.playlists")}
|
||||||
|
renderItem={(item: BaseItemDto) => {
|
||||||
|
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Text className='text-4xl'>🎶</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs'>
|
||||||
|
{item.ChildCount} tracks
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<JellyserrIndexPage
|
<JellyserrIndexPage
|
||||||
|
|||||||
21
app/(auth)/(tabs)/(settings)/_layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name='index'
|
||||||
|
options={{
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerTitle: t("tabs.settings"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(auth)/(tabs)/(settings)/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv";
|
||||||
|
|
||||||
|
export default function SettingsTabScreen() {
|
||||||
|
return <SettingsTV />;
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
@@ -16,10 +18,18 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import {
|
import {
|
||||||
useDeleteWatchlist,
|
useDeleteWatchlist,
|
||||||
useRemoveFromWatchlist,
|
useRemoveFromWatchlist,
|
||||||
@@ -31,9 +41,15 @@ import {
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 20;
|
||||||
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
|
||||||
export default function WatchlistDetailScreen() {
|
export default function WatchlistDetailScreen() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||||
@@ -46,6 +62,8 @@ export default function WatchlistDetailScreen() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
|
if (Platform.isTV) return 1;
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -152,6 +170,28 @@ export default function WatchlistDetailScreen() {
|
|||||||
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
(item: BaseItemDto, index: number) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navigation = getItemNavigation(item, "(watchlists)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TVPosterCard
|
||||||
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
|
orientation='vertical'
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={() => showItemActions(item)}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
width={posterSizes.poster}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router, showItemActions, posterSizes.poster],
|
||||||
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -265,6 +305,120 @@ export default function WatchlistDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TV layout with ScrollView + flexWrap
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 100,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingBottom: 24,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={20} color='#9ca3af' />
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{items?.length ?? 0}{" "}
|
||||||
|
{(items?.length ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{watchlist.isPublic
|
||||||
|
? t("watchlists.public")
|
||||||
|
: t("watchlists.private")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isOwner && (
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
|
||||||
|
{t("watchlists.by_owner")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid with flexWrap */}
|
||||||
|
{!items || items.length === 0 ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("watchlists.empty_watchlist")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => renderTVItem(item, index))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile layout with FlashList
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||||
|
|
||||||
export default function WatchlistsLayout() {
|
export default function WatchlistsLayout() {
|
||||||
@@ -22,14 +24,14 @@ export default function WatchlistsLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: streamystatsEnabled
|
headerRight: streamystatsEnabled
|
||||||
? () => (
|
? () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push("/(auth)/(tabs)/(watchlists)/create")
|
router.push("/(auth)/(tabs)/(watchlists)/create")
|
||||||
}
|
}
|
||||||
className='p-1.5'
|
className='p-1.5'
|
||||||
>
|
>
|
||||||
<Ionicons name='add' size={24} color='white' />
|
<Ionicons name='add' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
@@ -38,7 +40,7 @@ export default function WatchlistsLayout() {
|
|||||||
name='[watchlistId]'
|
name='[watchlistId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -49,7 +51,7 @@ export default function WatchlistsLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: t("watchlists.create_title"),
|
title: t("watchlists.create_title"),
|
||||||
presentation: "modal",
|
presentation: "modal",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerStyle: { backgroundColor: "#171717" },
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
headerTintColor: "white",
|
headerTintColor: "white",
|
||||||
contentStyle: { backgroundColor: "#171717" },
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
@@ -60,7 +62,7 @@ export default function WatchlistsLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: t("watchlists.edit_title"),
|
title: t("watchlists.edit_title"),
|
||||||
presentation: "modal",
|
presentation: "modal",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerStyle: { backgroundColor: "#171717" },
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
headerTintColor: "white",
|
headerTintColor: "white",
|
||||||
contentStyle: { backgroundColor: "#171717" },
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
import type {
|
import type {
|
||||||
StreamystatsWatchlistAllowedItemType,
|
StreamystatsWatchlistAllowedItemType,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -8,6 +7,7 @@ import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
useStreamystatsEnabled,
|
useStreamystatsEnabled,
|
||||||
useWatchlistsQuery,
|
useWatchlistsQuery,
|
||||||
|
|||||||
@@ -7,17 +7,22 @@ import type {
|
|||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
import { withLayoutContext } from "expo-router";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
|
||||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useTVBackHandler } from "@/hooks/useTVBackHandler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
// Music components are not available on tvOS (TrackPlayer not supported)
|
||||||
|
const MiniPlayerBar = Platform.isTV
|
||||||
|
? () => null
|
||||||
|
: require("@/components/music/MiniPlayerBar").MiniPlayerBar;
|
||||||
|
const MusicPlaybackEngine = Platform.isTV
|
||||||
|
? () => null
|
||||||
|
: require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine;
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
@@ -31,22 +36,9 @@ export const NativeTabs = withLayoutContext<
|
|||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useFocusEffect(
|
// Handle TV back button - prevent app exit when at root
|
||||||
useCallback(() => {
|
useTVBackHandler();
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
|
||||||
if (!hasShownIntro) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
router.push("/intro/page");
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
@@ -135,6 +127,17 @@ export default function TabLayout() {
|
|||||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name='(settings)'
|
||||||
|
options={{
|
||||||
|
title: t("tabs.settings"),
|
||||||
|
tabBarItemHidden: !Platform.isTV,
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS === "android"
|
||||||
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
|
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -20,11 +27,20 @@ import DraggableFlatList, {
|
|||||||
type RenderItemParams,
|
type RenderItemParams,
|
||||||
ScaleDecorator,
|
ScaleDecorator,
|
||||||
} from "react-native-draggable-flatlist";
|
} from "react-native-draggable-flatlist";
|
||||||
|
import { CastButton, CastState } from "react-native-google-cast";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import TextTicker from "react-native-text-ticker";
|
||||||
|
import type { VolumeResult } from "react-native-volume-manager";
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
import { useMusicCast } from "@/hooks/useMusicCast";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
type RepeatMode,
|
type RepeatMode,
|
||||||
useMusicPlayer,
|
useMusicPlayer,
|
||||||
@@ -32,6 +48,11 @@ import {
|
|||||||
import { formatBitrate } from "@/utils/bitrate";
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
import { formatDuration } from "@/utils/time";
|
import { formatDuration } from "@/utils/time";
|
||||||
|
|
||||||
|
// Conditionally require VolumeManager (not available on TV)
|
||||||
|
const VolumeManager = Platform.isTV
|
||||||
|
? null
|
||||||
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number | null) => {
|
const formatFileSize = (bytes?: number | null) => {
|
||||||
if (!bytes) return null;
|
if (!bytes) return null;
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
@@ -52,9 +73,22 @@ type ViewMode = "player" | "queue";
|
|||||||
|
|
||||||
export default function NowPlayingScreen() {
|
export default function NowPlayingScreen() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("player");
|
const [viewMode, setViewMode] = useState<ViewMode>("player");
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isConnected: isCastConnected,
|
||||||
|
castQueue,
|
||||||
|
castState,
|
||||||
|
} = useMusicCast({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentTrack,
|
currentTrack,
|
||||||
@@ -78,8 +112,13 @@ export default function NowPlayingScreen() {
|
|||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
reorderQueue,
|
reorderQueue,
|
||||||
stop,
|
stop,
|
||||||
|
pause,
|
||||||
} = useMusicPlayer();
|
} = useMusicPlayer();
|
||||||
|
|
||||||
|
const { isFavorite, toggleFavorite } = useFavorite(
|
||||||
|
currentTrack ?? ({ Id: "" } as BaseItemDto),
|
||||||
|
);
|
||||||
|
|
||||||
const sliderProgress = useSharedValue(0);
|
const sliderProgress = useSharedValue(0);
|
||||||
const sliderMin = useSharedValue(0);
|
const sliderMin = useSharedValue(0);
|
||||||
const sliderMax = useSharedValue(1);
|
const sliderMax = useSharedValue(1);
|
||||||
@@ -92,6 +131,21 @@ export default function NowPlayingScreen() {
|
|||||||
sliderMax.value = duration > 0 ? duration : 1;
|
sliderMax.value = duration > 0 ? duration : 1;
|
||||||
}, [duration, sliderMax]);
|
}, [duration, sliderMax]);
|
||||||
|
|
||||||
|
// Auto-cast queue when Chromecast becomes connected and pause local playback
|
||||||
|
const prevCastState = useRef<CastState | null | undefined>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
castState === CastState.CONNECTED &&
|
||||||
|
prevCastState.current !== CastState.CONNECTED &&
|
||||||
|
queue.length > 0
|
||||||
|
) {
|
||||||
|
// Just connected - pause local playback and cast the queue
|
||||||
|
pause();
|
||||||
|
castQueue({ queue, startIndex: queueIndex });
|
||||||
|
}
|
||||||
|
prevCastState.current = castState;
|
||||||
|
}, [castState, queue, queueIndex, castQueue, pause]);
|
||||||
|
|
||||||
const imageUrl = useMemo(() => {
|
const imageUrl = useMemo(() => {
|
||||||
if (!api || !currentTrack) return null;
|
if (!api || !currentTrack) return null;
|
||||||
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||||
@@ -106,11 +160,17 @@ export default function NowPlayingScreen() {
|
|||||||
return formatDuration(progressTicks);
|
return formatDuration(progressTicks);
|
||||||
}, [progress]);
|
}, [progress]);
|
||||||
|
|
||||||
const durationText = useMemo(() => {
|
const _durationText = useMemo(() => {
|
||||||
const durationTicks = duration * 10000000;
|
const durationTicks = duration * 10000000;
|
||||||
return formatDuration(durationTicks);
|
return formatDuration(durationTicks);
|
||||||
}, [duration]);
|
}, [duration]);
|
||||||
|
|
||||||
|
const remainingText = useMemo(() => {
|
||||||
|
const remaining = Math.max(0, duration - progress);
|
||||||
|
const remainingTicks = remaining * 10000000;
|
||||||
|
return `-${formatDuration(remainingTicks)}`;
|
||||||
|
}, [duration, progress]);
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
const handleSliderComplete = useCallback(
|
||||||
(value: number) => {
|
(value: number) => {
|
||||||
seek(value);
|
seek(value);
|
||||||
@@ -134,6 +194,18 @@ export default function NowPlayingScreen() {
|
|||||||
setRepeatMode(nextMode);
|
setRepeatMode(nextMode);
|
||||||
}, [repeatMode, setRepeatMode]);
|
}, [repeatMode, setRepeatMode]);
|
||||||
|
|
||||||
|
const handleOptionsPress = useCallback(() => {
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getRepeatIcon = (): string => {
|
const getRepeatIcon = (): string => {
|
||||||
switch (repeatMode) {
|
switch (repeatMode) {
|
||||||
case "one":
|
case "one":
|
||||||
@@ -150,108 +222,133 @@ export default function NowPlayingScreen() {
|
|||||||
|
|
||||||
if (!currentTrack) {
|
if (!currentTrack) {
|
||||||
return (
|
return (
|
||||||
|
<BottomSheetModalProvider>
|
||||||
|
<View
|
||||||
|
className='flex-1 bg-[#121212] items-center justify-center'
|
||||||
|
style={{
|
||||||
|
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||||
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-neutral-500'>No track playing</Text>
|
||||||
|
</View>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModalProvider>
|
||||||
<View
|
<View
|
||||||
className='flex-1 bg-[#121212] items-center justify-center'
|
className='flex-1 bg-[#121212]'
|
||||||
style={{
|
style={{
|
||||||
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-neutral-500'>No track playing</Text>
|
{/* Header */}
|
||||||
</View>
|
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex-1 bg-[#121212]'
|
|
||||||
style={{
|
|
||||||
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
|
||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleClose}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
className='p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-down' size={28} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<View className='flex-row'>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setViewMode("player")}
|
onPress={handleClose}
|
||||||
className='px-3 py-1'
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
className='p-2'
|
||||||
>
|
>
|
||||||
<Text
|
<Ionicons name='chevron-down' size={28} color='white' />
|
||||||
className={
|
|
||||||
viewMode === "player"
|
|
||||||
? "text-white font-semibold"
|
|
||||||
: "text-neutral-500"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Now Playing
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setViewMode("queue")}
|
<View className='flex-row'>
|
||||||
className='px-3 py-1'
|
<TouchableOpacity
|
||||||
>
|
onPress={() => setViewMode("player")}
|
||||||
<Text
|
className='px-3 py-1'
|
||||||
className={
|
|
||||||
viewMode === "queue"
|
|
||||||
? "text-white font-semibold"
|
|
||||||
: "text-neutral-500"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Queue ({queue.length})
|
<Text
|
||||||
</Text>
|
className={
|
||||||
</TouchableOpacity>
|
viewMode === "player"
|
||||||
|
? "text-white font-semibold"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Now Playing
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setViewMode("queue")}
|
||||||
|
className='px-3 py-1'
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
viewMode === "queue"
|
||||||
|
? "text-white font-semibold"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Queue ({queue.length})
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{/* Empty placeholder to balance header layout */}
|
||||||
|
<View className='p-2' style={{ width: 44 }} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ width: 16 }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{viewMode === "player" ? (
|
{viewMode === "player" ? (
|
||||||
<PlayerView
|
<PlayerView
|
||||||
api={api}
|
api={api}
|
||||||
currentTrack={currentTrack}
|
currentTrack={currentTrack}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
sliderProgress={sliderProgress}
|
sliderProgress={sliderProgress}
|
||||||
sliderMin={sliderMin}
|
sliderMin={sliderMin}
|
||||||
sliderMax={sliderMax}
|
sliderMax={sliderMax}
|
||||||
progressText={progressText}
|
progressText={progressText}
|
||||||
durationText={durationText}
|
remainingText={remainingText}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
repeatMode={repeatMode}
|
repeatMode={repeatMode}
|
||||||
shuffleEnabled={shuffleEnabled}
|
shuffleEnabled={shuffleEnabled}
|
||||||
canGoNext={canGoNext}
|
canGoNext={canGoNext}
|
||||||
canGoPrevious={canGoPrevious}
|
canGoPrevious={canGoPrevious}
|
||||||
onSliderComplete={handleSliderComplete}
|
onSliderComplete={handleSliderComplete}
|
||||||
onTogglePlayPause={togglePlayPause}
|
onTogglePlayPause={togglePlayPause}
|
||||||
onNext={next}
|
onNext={next}
|
||||||
onPrevious={previous}
|
onPrevious={previous}
|
||||||
onCycleRepeat={cycleRepeatMode}
|
onCycleRepeat={cycleRepeatMode}
|
||||||
onToggleShuffle={toggleShuffle}
|
onToggleShuffle={toggleShuffle}
|
||||||
getRepeatIcon={getRepeatIcon}
|
getRepeatIcon={getRepeatIcon}
|
||||||
queue={queue}
|
mediaSource={mediaSource}
|
||||||
queueIndex={queueIndex}
|
isTranscoding={isTranscoding}
|
||||||
mediaSource={mediaSource}
|
isFavorite={isFavorite}
|
||||||
isTranscoding={isTranscoding}
|
onToggleFavorite={toggleFavorite}
|
||||||
|
onOptionsPress={handleOptionsPress}
|
||||||
|
isCastConnected={isCastConnected}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<QueueView
|
||||||
|
api={api}
|
||||||
|
queue={queue}
|
||||||
|
queueIndex={queueIndex}
|
||||||
|
onJumpToIndex={jumpToIndex}
|
||||||
|
onRemoveFromQueue={removeFromQueue}
|
||||||
|
onReorderQueue={reorderQueue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={currentTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
/>
|
/>
|
||||||
) : (
|
<PlaylistPickerSheet
|
||||||
<QueueView
|
open={playlistPickerOpen}
|
||||||
api={api}
|
setOpen={setPlaylistPickerOpen}
|
||||||
queue={queue}
|
trackToAdd={currentTrack}
|
||||||
queueIndex={queueIndex}
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
onJumpToIndex={jumpToIndex}
|
|
||||||
onRemoveFromQueue={removeFromQueue}
|
|
||||||
onReorderQueue={reorderQueue}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<CreatePlaylistModal
|
||||||
</View>
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={currentTrack?.Id}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +360,7 @@ interface PlayerViewProps {
|
|||||||
sliderMin: any;
|
sliderMin: any;
|
||||||
sliderMax: any;
|
sliderMax: any;
|
||||||
progressText: string;
|
progressText: string;
|
||||||
durationText: string;
|
remainingText: string;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
repeatMode: RepeatMode;
|
repeatMode: RepeatMode;
|
||||||
@@ -277,10 +374,12 @@ interface PlayerViewProps {
|
|||||||
onCycleRepeat: () => void;
|
onCycleRepeat: () => void;
|
||||||
onToggleShuffle: () => void;
|
onToggleShuffle: () => void;
|
||||||
getRepeatIcon: () => string;
|
getRepeatIcon: () => string;
|
||||||
queue: BaseItemDto[];
|
|
||||||
queueIndex: number;
|
|
||||||
mediaSource: MediaSourceInfo | null;
|
mediaSource: MediaSourceInfo | null;
|
||||||
isTranscoding: boolean;
|
isTranscoding: boolean;
|
||||||
|
isFavorite: boolean | undefined;
|
||||||
|
onToggleFavorite: () => void;
|
||||||
|
onOptionsPress: () => void;
|
||||||
|
isCastConnected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayerView: React.FC<PlayerViewProps> = ({
|
const PlayerView: React.FC<PlayerViewProps> = ({
|
||||||
@@ -290,7 +389,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
sliderMin,
|
sliderMin,
|
||||||
sliderMax,
|
sliderMax,
|
||||||
progressText,
|
progressText,
|
||||||
durationText,
|
remainingText,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isLoading,
|
isLoading,
|
||||||
repeatMode,
|
repeatMode,
|
||||||
@@ -304,15 +403,42 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
onCycleRepeat,
|
onCycleRepeat,
|
||||||
onToggleShuffle,
|
onToggleShuffle,
|
||||||
getRepeatIcon,
|
getRepeatIcon,
|
||||||
queue,
|
|
||||||
queueIndex,
|
|
||||||
mediaSource,
|
mediaSource,
|
||||||
isTranscoding,
|
isTranscoding,
|
||||||
|
isFavorite,
|
||||||
|
onToggleFavorite,
|
||||||
|
onOptionsPress,
|
||||||
|
isCastConnected,
|
||||||
}) => {
|
}) => {
|
||||||
const audioStream = useMemo(() => {
|
const audioStream = useMemo(() => {
|
||||||
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
||||||
}, [mediaSource]);
|
}, [mediaSource]);
|
||||||
|
|
||||||
|
// Volume slider state
|
||||||
|
const volumeProgress = useSharedValue(0);
|
||||||
|
const volumeMin = useSharedValue(0);
|
||||||
|
const volumeMax = useSharedValue(1);
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTv || !VolumeManager) return;
|
||||||
|
// Get initial volume
|
||||||
|
VolumeManager.getVolume().then(({ volume }: { volume: number }) => {
|
||||||
|
volumeProgress.value = volume;
|
||||||
|
});
|
||||||
|
// Listen to volume changes
|
||||||
|
const listener = VolumeManager.addVolumeListener((result: VolumeResult) => {
|
||||||
|
volumeProgress.value = result.volume;
|
||||||
|
});
|
||||||
|
return () => listener.remove();
|
||||||
|
}, [isTv, volumeProgress]);
|
||||||
|
|
||||||
|
const handleVolumeChange = useCallback((value: number) => {
|
||||||
|
if (VolumeManager) {
|
||||||
|
VolumeManager.setVolume(value);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fileSize = formatFileSize(mediaSource?.Size);
|
const fileSize = formatFileSize(mediaSource?.Size);
|
||||||
const codec = audioStream?.Codec?.toUpperCase();
|
const codec = audioStream?.Codec?.toUpperCase();
|
||||||
const bitrate = formatBitrate(audioStream?.BitRate);
|
const bitrate = formatBitrate(audioStream?.BitRate);
|
||||||
@@ -353,19 +479,60 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Track info */}
|
{/* Track info with actions */}
|
||||||
<View className='mb-6'>
|
<View className='mb-6'>
|
||||||
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
<View className='flex-row items-start justify-between'>
|
||||||
{currentTrack.Name}
|
<View className='flex-1 mr-4'>
|
||||||
</Text>
|
<TextTicker
|
||||||
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'>
|
style={{ color: "white", fontSize: 24, fontWeight: "bold" }}
|
||||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
duration={Math.max(4000, (currentTrack.Name?.length || 0) * 250)}
|
||||||
</Text>
|
loop
|
||||||
{currentTrack.Album && (
|
bounce={false}
|
||||||
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'>
|
repeatSpacer={80}
|
||||||
{currentTrack.Album}
|
marqueeDelay={1500}
|
||||||
</Text>
|
scroll={false}
|
||||||
)}
|
animationType='scroll'
|
||||||
|
easing={(t) => t}
|
||||||
|
>
|
||||||
|
{currentTrack.Name}
|
||||||
|
</TextTicker>
|
||||||
|
<TextTicker
|
||||||
|
style={{ color: "#a3a3a3", fontSize: 18 }}
|
||||||
|
duration={Math.max(
|
||||||
|
4000,
|
||||||
|
(
|
||||||
|
currentTrack.Artists?.join(", ") ||
|
||||||
|
currentTrack.AlbumArtist ||
|
||||||
|
""
|
||||||
|
).length * 250,
|
||||||
|
)}
|
||||||
|
loop
|
||||||
|
bounce={false}
|
||||||
|
repeatSpacer={80}
|
||||||
|
marqueeDelay={2000}
|
||||||
|
scroll={false}
|
||||||
|
animationType='scroll'
|
||||||
|
easing={(t) => t}
|
||||||
|
>
|
||||||
|
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||||
|
</TextTicker>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onToggleFavorite}
|
||||||
|
className='p-2'
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isFavorite ? "heart" : "heart-outline"}
|
||||||
|
size={24}
|
||||||
|
color={isFavorite ? "#ec4899" : "white"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={onOptionsPress} className='p-2'>
|
||||||
|
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Audio Stats */}
|
{/* Audio Stats */}
|
||||||
{hasAudioStats && (
|
{hasAudioStats && (
|
||||||
@@ -395,28 +562,36 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<Slider
|
<Slider
|
||||||
theme={{
|
theme={{
|
||||||
maximumTrackTintColor: "#333",
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
minimumTrackTintColor: "#9334E9",
|
minimumTrackTintColor: "#fff",
|
||||||
bubbleBackgroundColor: "#9334E9",
|
bubbleBackgroundColor: "#fff",
|
||||||
bubbleTextColor: "#fff",
|
bubbleTextColor: "#666",
|
||||||
}}
|
}}
|
||||||
progress={sliderProgress}
|
progress={sliderProgress}
|
||||||
minimumValue={sliderMin}
|
minimumValue={sliderMin}
|
||||||
maximumValue={sliderMax}
|
maximumValue={sliderMax}
|
||||||
onSlidingComplete={onSliderComplete}
|
onSlidingComplete={onSliderComplete}
|
||||||
thumbWidth={16}
|
renderThumb={() => null}
|
||||||
sliderHeight={6}
|
sliderHeight={8}
|
||||||
containerStyle={{ borderRadius: 10 }}
|
containerStyle={{ borderRadius: 100 }}
|
||||||
renderBubble={() => null}
|
renderBubble={() => null}
|
||||||
/>
|
/>
|
||||||
<View className='flex flex-row justify-between px-1 mt-2'>
|
<View className='flex flex-row justify-between mt-2'>
|
||||||
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
||||||
<Text className='text-neutral-500 text-xs'>{durationText}</Text>
|
<Text className='text-neutral-500 text-xs'>{remainingText}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Main Controls */}
|
{/* Main Controls with Shuffle & Repeat */}
|
||||||
<View className='flex flex-row items-center justify-center mb-2'>
|
<View className='flex flex-row items-center justify-center mb-6'>
|
||||||
|
<TouchableOpacity onPress={onToggleShuffle} className='p-3'>
|
||||||
|
<Ionicons
|
||||||
|
name='shuffle'
|
||||||
|
size={24}
|
||||||
|
color={shuffleEnabled ? "#9334E9" : "#666"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPrevious}
|
onPress={onPrevious}
|
||||||
disabled={!canGoPrevious || isLoading}
|
disabled={!canGoPrevious || isLoading}
|
||||||
@@ -429,7 +604,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onTogglePlayPause}
|
onPress={onTogglePlayPause}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className='mx-8 bg-white rounded-full p-4'
|
className='mx-4 bg-white rounded-full p-4'
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator size={36} color='#121212' />
|
<ActivityIndicator size={36} color='#121212' />
|
||||||
@@ -451,38 +626,75 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
>
|
>
|
||||||
<Ionicons name='play-skip-forward' size={32} color='white' />
|
<Ionicons name='play-skip-forward' size={32} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Shuffle & Repeat Controls */}
|
<TouchableOpacity onPress={onCycleRepeat} className='p-3 relative'>
|
||||||
<View className='flex flex-row items-center justify-center mb-2'>
|
|
||||||
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
|
|
||||||
<Ionicons
|
|
||||||
name='shuffle'
|
|
||||||
size={24}
|
|
||||||
color={shuffleEnabled ? "#9334E9" : "#666"}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={onCycleRepeat} className='p-3 mx-4 relative'>
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={getRepeatIcon() as any}
|
name={getRepeatIcon() as any}
|
||||||
size={24}
|
size={24}
|
||||||
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
||||||
/>
|
/>
|
||||||
{repeatMode === "one" && (
|
{repeatMode === "one" && (
|
||||||
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
<View className='absolute right-0 top-1 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
||||||
<Text className='text-white text-[10px] font-bold'>1</Text>
|
<Text className='text-white text-[10px] font-bold'>1</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Queue info */}
|
{/* Volume Slider */}
|
||||||
{queue.length > 1 && (
|
{!isTv && VolumeManager && (
|
||||||
<View className='items-center mb-4'>
|
<View className='flex-row items-center mb-6'>
|
||||||
<Text className='text-neutral-500 text-sm'>
|
<Ionicons name='volume-low' size={20} color='#666' />
|
||||||
{queueIndex + 1} of {queue.length}
|
<View className='flex-1 mx-3'>
|
||||||
</Text>
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
}}
|
||||||
|
progress={volumeProgress}
|
||||||
|
minimumValue={volumeMin}
|
||||||
|
maximumValue={volumeMax}
|
||||||
|
onSlidingComplete={handleVolumeChange}
|
||||||
|
renderThumb={() => null}
|
||||||
|
sliderHeight={8}
|
||||||
|
containerStyle={{ borderRadius: 100 }}
|
||||||
|
renderBubble={() => null}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Ionicons name='volume-high' size={20} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AirPlay & Chromecast Buttons */}
|
||||||
|
{!isTv && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 32,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* AirPlay (iOS only) */}
|
||||||
|
{Platform.OS === "ios" && (
|
||||||
|
<View style={{ transform: [{ scale: 2.8 }] }}>
|
||||||
|
<ExpoAvRoutePickerView
|
||||||
|
style={{ width: 24, height: 24 }}
|
||||||
|
tintColor='#666666'
|
||||||
|
activeTintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Chromecast */}
|
||||||
|
<CastButton
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
tintColor: isCastConnected ? "#9334E9" : "#666",
|
||||||
|
transform: [{ translateY: 1 }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
171
app/(auth)/tv-option-modal.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVOptionCard } from "@/components/tv";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
export default function TVOptionModal() {
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvOptionModalAtom);
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const firstCardRef = useRef<View>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
const initialSelectedIndex = useMemo(() => {
|
||||||
|
if (!modalState?.options) return 0;
|
||||||
|
const idx = modalState.options.findIndex((o) => o.selected);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}, [modalState?.options]);
|
||||||
|
|
||||||
|
// Animate in on mount and cleanup atom on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
// Delay focus setup to allow layout
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
// Clear the atom on unmount to prevent stale callbacks from being retained
|
||||||
|
store.set(tvOptionModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Request focus on the first card when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
const handleSelect = (value: any) => {
|
||||||
|
modalState?.onSelect(value);
|
||||||
|
store.set(tvOptionModalAtom, null);
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no modal state, just go back (shouldn't happen in normal usage)
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, options, cardWidth = 160, cardHeight = 75 } = modalState;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={index}
|
||||||
|
ref={
|
||||||
|
index === initialSelectedIndex ? firstCardRef : undefined
|
||||||
|
}
|
||||||
|
label={option.label}
|
||||||
|
sublabel={option.sublabel}
|
||||||
|
selected={option.selected}
|
||||||
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||||
|
onPress={() => handleSelect(option.value)}
|
||||||
|
width={cardWidth}
|
||||||
|
height={cardHeight}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 20,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
496
app/(auth)/tv-request-modal.tsx
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
|
||||||
|
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
||||||
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||||
|
import type {
|
||||||
|
QualityProfile,
|
||||||
|
RootFolder,
|
||||||
|
Tag,
|
||||||
|
} from "@/utils/jellyseerr/server/api/servarr/base";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
export default function TVRequestModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvRequestModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||||
|
mediaId: modalState?.id ? Number(modalState.id) : 0,
|
||||||
|
mediaType: modalState?.mediaType,
|
||||||
|
userId: jellyseerrUser?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeSelector, setActiveSelector] = useState<
|
||||||
|
"profile" | "folder" | "user" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
store.set(tvRequestModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
const { data: serviceSettings } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.service(
|
||||||
|
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
|
||||||
|
),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: users } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "users"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultService = useMemo(
|
||||||
|
() => serviceSettings?.find?.((v) => v.isDefault),
|
||||||
|
[serviceSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: defaultServiceDetails } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"jellyseerr",
|
||||||
|
"request",
|
||||||
|
modalState?.mediaType,
|
||||||
|
"service",
|
||||||
|
"details",
|
||||||
|
defaultService?.id,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serverId: defaultService?.id,
|
||||||
|
}));
|
||||||
|
return jellyseerrApi?.serviceDetails(
|
||||||
|
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
|
||||||
|
defaultService!.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
!!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProfile: QualityProfile | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.profiles.find(
|
||||||
|
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
|
||||||
|
),
|
||||||
|
[defaultServiceDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultFolder: RootFolder | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.rootFolders.find(
|
||||||
|
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
|
||||||
|
),
|
||||||
|
[defaultServiceDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultTags: Tag[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
defaultServiceDetails?.tags.filter((t) =>
|
||||||
|
defaultServiceDetails?.server.activeTags?.includes(t.id),
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}, [defaultServiceDetails]);
|
||||||
|
|
||||||
|
const pathTitleExtractor = (item: RootFolder) =>
|
||||||
|
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||||
|
|
||||||
|
// Option builders
|
||||||
|
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.profiles.map((profile) => ({
|
||||||
|
label: profile.name,
|
||||||
|
value: profile.id,
|
||||||
|
selected:
|
||||||
|
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
|
||||||
|
})) || [],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
defaultProfile,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||||
|
label: pathTitleExtractor(folder),
|
||||||
|
value: folder.path,
|
||||||
|
selected:
|
||||||
|
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
|
||||||
|
})) || [],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
defaultFolder,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const userOptions: TVOptionItem<number>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
users?.map((user) => ({
|
||||||
|
label: user.displayName,
|
||||||
|
value: user.id,
|
||||||
|
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||||
|
})) || [],
|
||||||
|
[users, jellyseerrUser, requestOverrides.userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagItems = useMemo(() => {
|
||||||
|
return (
|
||||||
|
defaultServiceDetails?.tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
label: tag.label,
|
||||||
|
selected:
|
||||||
|
requestOverrides.tags?.includes(tag.id) ||
|
||||||
|
defaultTags.some((dt) => dt.id === tag.id),
|
||||||
|
})) ?? []
|
||||||
|
);
|
||||||
|
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
|
||||||
|
|
||||||
|
// Selected display values
|
||||||
|
const selectedProfileName = useMemo(() => {
|
||||||
|
const profile = defaultServiceDetails?.profiles.find(
|
||||||
|
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
|
||||||
|
);
|
||||||
|
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
|
||||||
|
}, [
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
defaultProfile,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedFolderName = useMemo(() => {
|
||||||
|
const folder = defaultServiceDetails?.rootFolders.find(
|
||||||
|
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
|
||||||
|
);
|
||||||
|
return folder
|
||||||
|
? pathTitleExtractor(folder)
|
||||||
|
: defaultFolder
|
||||||
|
? pathTitleExtractor(defaultFolder)
|
||||||
|
: t("jellyseerr.select");
|
||||||
|
}, [
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
defaultFolder,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedUserName = useMemo(() => {
|
||||||
|
const user = users?.find(
|
||||||
|
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
|
||||||
|
);
|
||||||
|
}, [users, requestOverrides.userId, jellyseerrUser, t]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleProfileChange = useCallback((profileId: number) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, profileId }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFolderChange = useCallback((rootFolder: string) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUserChange = useCallback((userId: number) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, userId }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTagToggle = useCallback(
|
||||||
|
(tagId: number) => {
|
||||||
|
setRequestOverrides((prev) => {
|
||||||
|
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||||
|
const hasTag = currentTags.includes(tagId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tags: hasTag
|
||||||
|
? currentTags.filter((id) => id !== tagId)
|
||||||
|
: [...currentTags, tagId],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defaultTags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRequest = useCallback(() => {
|
||||||
|
if (!modalState) return;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||||
|
profileId: defaultProfile?.id,
|
||||||
|
rootFolder: defaultFolder?.path,
|
||||||
|
tags: defaultTags.map((t) => t.id),
|
||||||
|
...modalState.requestBody,
|
||||||
|
...requestOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const seasonTitle =
|
||||||
|
modalState.requestBody?.seasons?.length === 1
|
||||||
|
? t("jellyseerr.season_number", {
|
||||||
|
season_number: modalState.requestBody.seasons[0],
|
||||||
|
})
|
||||||
|
: modalState.requestBody?.seasons &&
|
||||||
|
modalState.requestBody.seasons.length > 1
|
||||||
|
? t("jellyseerr.season_all")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
requestMedia(
|
||||||
|
seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title,
|
||||||
|
body,
|
||||||
|
() => {
|
||||||
|
modalState.onRequested();
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
modalState,
|
||||||
|
requestOverrides,
|
||||||
|
defaultProfile,
|
||||||
|
defaultFolder,
|
||||||
|
defaultTags,
|
||||||
|
defaultService,
|
||||||
|
defaultServiceDetails,
|
||||||
|
requestMedia,
|
||||||
|
router,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDataLoaded = defaultService && defaultServiceDetails && users;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
|
{t("jellyseerr.advanced")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isDataLoaded && isReady ? (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.optionsContainer}>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.quality_profile")}
|
||||||
|
value={selectedProfileName}
|
||||||
|
onPress={() => setActiveSelector("profile")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.root_folder")}
|
||||||
|
value={selectedFolderName}
|
||||||
|
onPress={() => setActiveSelector("folder")}
|
||||||
|
/>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.request_as")}
|
||||||
|
value={selectedUserName}
|
||||||
|
onPress={() => setActiveSelector("user")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tagItems.length > 0 && (
|
||||||
|
<TVToggleOptionRow
|
||||||
|
label={t("jellyseerr.tags")}
|
||||||
|
items={tagItems}
|
||||||
|
onToggle={handleTagToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>{t("common.loading")}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequest}
|
||||||
|
variant='secondary'
|
||||||
|
disabled={!isDataLoaded}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.buttonText,
|
||||||
|
{ fontSize: typography.callout },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_button")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Sub-selectors */}
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "profile"}
|
||||||
|
title={t("jellyseerr.quality_profile")}
|
||||||
|
options={qualityProfileOptions}
|
||||||
|
onSelect={handleProfileChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "folder"}
|
||||||
|
title={t("jellyseerr.root_folder")}
|
||||||
|
options={rootFolderOptions}
|
||||||
|
onSelect={handleFolderChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
cardWidth={280}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "user"}
|
||||||
|
title={t("jellyseerr.request_as")}
|
||||||
|
options={userOptions}
|
||||||
|
onSelect={handleUserChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 44,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
maxHeight: 320,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
optionsContainer: {
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
height: 200,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
},
|
||||||
|
});
|
||||||
446
app/(auth)/tv-season-select-modal.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { orderBy } from "lodash";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVButton } from "@/components/tv";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||||
|
import { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal";
|
||||||
|
import {
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
interface TVSeasonToggleCardProps {
|
||||||
|
season: {
|
||||||
|
id: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeCount: number;
|
||||||
|
status: MediaStatus;
|
||||||
|
};
|
||||||
|
selected: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
canRequest: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
||||||
|
season,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
canRequest,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
|
// Get status icon and color based on MediaStatus
|
||||||
|
const getStatusIcon = (): {
|
||||||
|
icon: keyof typeof MaterialCommunityIcons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
} | null => {
|
||||||
|
switch (season.status) {
|
||||||
|
case MediaStatus.PROCESSING:
|
||||||
|
return { icon: "clock", color: "#6366f1" };
|
||||||
|
case MediaStatus.AVAILABLE:
|
||||||
|
return { icon: "check", color: "#22c55e" };
|
||||||
|
case MediaStatus.PENDING:
|
||||||
|
return { icon: "bell", color: "#eab308" };
|
||||||
|
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||||
|
return { icon: "minus", color: "#22c55e" };
|
||||||
|
case MediaStatus.BLACKLISTED:
|
||||||
|
return { icon: "eye-off", color: "#ef4444" };
|
||||||
|
default:
|
||||||
|
return canRequest ? { icon: "plus", color: "#22c55e" } : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusInfo = getStatusIcon();
|
||||||
|
const isDisabled = !canRequest;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={canRequest ? onToggle : undefined}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={isDisabled}
|
||||||
|
focusable={!isDisabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
styles.seasonCard,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#FFFFFF"
|
||||||
|
: selected
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderWidth: focused ? 0 : 1,
|
||||||
|
borderColor: selected
|
||||||
|
? "rgba(255,255,255,0.4)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Checkmark for selected */}
|
||||||
|
<View style={styles.checkmarkContainer}>
|
||||||
|
{selected && (
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-circle'
|
||||||
|
size={24}
|
||||||
|
color={focused ? "#22c55e" : "#FFFFFF"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Season info */}
|
||||||
|
<View style={styles.seasonInfo}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.seasonTitle,
|
||||||
|
{ color: focused ? "#000000" : "#FFFFFF" },
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.season_number", {
|
||||||
|
season_number: season.seasonNumber,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.episodeRow}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.episodeCount,
|
||||||
|
{
|
||||||
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.number_episodes", {
|
||||||
|
episode_number: season.episodeCount,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{statusInfo && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: statusInfo.color },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={statusInfo.icon}
|
||||||
|
size={14}
|
||||||
|
color='#FFFFFF'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TVSeasonSelectModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { requestMedia } = useJellyseerr();
|
||||||
|
const { showRequestModal } = useTVRequestModal();
|
||||||
|
|
||||||
|
// Selected seasons - initially select all requestable (UNKNOWN status) seasons
|
||||||
|
const [selectedSeasons, setSelectedSeasons] = useState<Set<number>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
// Initialize selected seasons when modal state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (modalState?.seasons) {
|
||||||
|
const requestableSeasons = modalState.seasons
|
||||||
|
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
|
||||||
|
.map((s) => s.seasonNumber);
|
||||||
|
setSelectedSeasons(new Set(requestableSeasons));
|
||||||
|
}
|
||||||
|
}, [modalState?.seasons]);
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
store.set(tvSeasonSelectModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Sort seasons by season number (ascending)
|
||||||
|
const sortedSeasons = useMemo(() => {
|
||||||
|
if (!modalState?.seasons) return [];
|
||||||
|
return orderBy(
|
||||||
|
modalState.seasons.filter((s) => s.seasonNumber !== 0),
|
||||||
|
"seasonNumber",
|
||||||
|
"asc",
|
||||||
|
);
|
||||||
|
}, [modalState?.seasons]);
|
||||||
|
|
||||||
|
// Find the index of the first requestable season for initial focus
|
||||||
|
const firstRequestableIndex = useMemo(() => {
|
||||||
|
return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN);
|
||||||
|
}, [sortedSeasons]);
|
||||||
|
|
||||||
|
const handleToggleSeason = useCallback((seasonNumber: number) => {
|
||||||
|
setSelectedSeasons((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(seasonNumber)) {
|
||||||
|
newSet.delete(seasonNumber);
|
||||||
|
} else {
|
||||||
|
newSet.add(seasonNumber);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRequestSelected = useCallback(() => {
|
||||||
|
if (!modalState || selectedSeasons.size === 0) return;
|
||||||
|
|
||||||
|
const seasonsArray = Array.from(selectedSeasons);
|
||||||
|
const body: MediaRequestBody = {
|
||||||
|
mediaId: modalState.mediaId,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: modalState.tvdbId,
|
||||||
|
seasons: seasonsArray,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (modalState.hasAdvancedRequestPermission) {
|
||||||
|
// Close this modal and open the advanced request modal
|
||||||
|
router.back();
|
||||||
|
showRequestModal({
|
||||||
|
requestBody: body,
|
||||||
|
title: modalState.title,
|
||||||
|
id: modalState.mediaId,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
onRequested: modalState.onRequested,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the title based on selected seasons
|
||||||
|
const seasonTitle =
|
||||||
|
seasonsArray.length === 1
|
||||||
|
? t("jellyseerr.season_number", { season_number: seasonsArray[0] })
|
||||||
|
: seasonsArray.length === sortedSeasons.length
|
||||||
|
? t("jellyseerr.season_all")
|
||||||
|
: t("jellyseerr.n_selected", { count: seasonsArray.length });
|
||||||
|
|
||||||
|
requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => {
|
||||||
|
modalState.onRequested();
|
||||||
|
router.back();
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
modalState,
|
||||||
|
selectedSeasons,
|
||||||
|
sortedSeasons.length,
|
||||||
|
requestMedia,
|
||||||
|
router,
|
||||||
|
t,
|
||||||
|
showRequestModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
|
{t("jellyseerr.select_seasons")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Season cards horizontal scroll */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{sortedSeasons.map((season, index) => {
|
||||||
|
const canRequestSeason = season.status === MediaStatus.UNKNOWN;
|
||||||
|
return (
|
||||||
|
<TVSeasonToggleCard
|
||||||
|
key={season.id}
|
||||||
|
season={season}
|
||||||
|
selected={selectedSeasons.has(season.seasonNumber)}
|
||||||
|
onToggle={() => handleToggleSeason(season.seasonNumber)}
|
||||||
|
canRequest={canRequestSeason}
|
||||||
|
hasTVPreferredFocus={index === firstRequestableIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Request button */}
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequestSelected}
|
||||||
|
variant='secondary'
|
||||||
|
disabled={selectedSeasons.size === 0}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.buttonText, { fontSize: typography.callout }]}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_selected")}
|
||||||
|
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 44,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
seasonCard: {
|
||||||
|
width: 160,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
},
|
||||||
|
checkmarkContainer: {
|
||||||
|
height: 24,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
seasonInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
seasonTitle: {
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
episodeRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
episodeCount: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
},
|
||||||
|
});
|
||||||
190
app/(auth)/tv-series-season-modal.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVCancelButton, TVOptionCard } from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
export default function TVSeriesSeasonModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const firstCardRef = useRef<View>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
const initialSelectedIndex = useMemo(() => {
|
||||||
|
if (!modalState?.seasons) return 0;
|
||||||
|
const idx = modalState.seasons.findIndex((o) => o.selected);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}, [modalState?.seasons]);
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
store.set(tvSeriesSeasonModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Focus on the selected card when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
const handleSelect = (seasonIndex: number) => {
|
||||||
|
if (modalState?.onSeasonSelect) {
|
||||||
|
modalState.onSeasonSelect(seasonIndex);
|
||||||
|
}
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={[styles.title, { fontSize: typography.callout }]}>
|
||||||
|
{t("item_card.select_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{modalState.seasons.map((season, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={season.value}
|
||||||
|
ref={
|
||||||
|
index === initialSelectedIndex ? firstCardRef : undefined
|
||||||
|
}
|
||||||
|
label={season.label}
|
||||||
|
selected={season.selected}
|
||||||
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||||
|
onPress={() => handleSelect(season.value)}
|
||||||
|
width={180}
|
||||||
|
height={85}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.cancelButtonContainer}>
|
||||||
|
<TVCancelButton
|
||||||
|
onPress={handleCancel}
|
||||||
|
label={t("common.cancel")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 20,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
cancelButtonContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
});
|
||||||
1300
app/(auth)/tv-subtitle-modal.tsx
Normal file
174
app/(auth)/tv-user-switch-modal.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVUserCard } from "@/components/tv/TVUserCard";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
|
||||||
|
import type { SavedServerAccount } from "@/utils/secureCredentials";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
export default function TVUserSwitchModalPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvUserSwitchModalAtom);
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const firstCardRef = useRef<View>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
// Animate in on mount and cleanup atom on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
// Delay focus setup to allow layout
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
// Clear the atom on unmount to prevent stale callbacks from being retained
|
||||||
|
store.set(tvUserSwitchModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Request focus on the first card when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
const handleSelect = (account: SavedServerAccount) => {
|
||||||
|
modalState?.onAccountSelect(account);
|
||||||
|
store.set(tvUserSwitchModalAtom, null);
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no modal state, just return null
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{t("home.settings.switch_user.title")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subtitle}>{modalState.serverName}</Text>
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{modalState.accounts.map((account, index) => {
|
||||||
|
const isCurrent = account.userId === modalState.currentUserId;
|
||||||
|
return (
|
||||||
|
<TVUserCard
|
||||||
|
key={account.userId}
|
||||||
|
ref={index === 0 ? firstCardRef : undefined}
|
||||||
|
username={account.username}
|
||||||
|
securityType={account.securityType}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
onPress={() => handleSelect(account)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "rgba(255,255,255,0.4)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 20,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
292
app/_layout.tsx
@@ -1,17 +1,21 @@
|
|||||||
import "@/augmentations";
|
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 { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import { 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 { 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 {
|
import {
|
||||||
apiAtom,
|
apiAtom,
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
@@ -20,6 +24,7 @@ import {
|
|||||||
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
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 { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
@@ -45,20 +50,37 @@ import type {
|
|||||||
NotificationResponse,
|
NotificationResponse,
|
||||||
} from "expo-notifications/build/Notifications.types";
|
} from "expo-notifications/build/Notifications.types";
|
||||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||||
import { router, Stack, useSegments } from "expo-router";
|
import { Stack, useSegments } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import * as TaskManager from "expo-task-manager";
|
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 { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { store } from "@/utils/store";
|
import { store as jotaiStore, store } from "@/utils/store";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
|
import {
|
||||||
|
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 () => ({
|
||||||
@@ -78,14 +100,9 @@ SplashScreen.setOptions({
|
|||||||
fade: true,
|
fade: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function redirect(notification: typeof Notifications.Notification) {
|
|
||||||
const url = notification.request.content.data?.url;
|
|
||||||
if (url) {
|
|
||||||
router.push(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -96,14 +113,17 @@ function useNotificationObserver() {
|
|||||||
if (!isMounted || !response?.notification) {
|
if (!isMounted || !response?.notification) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect(response?.notification);
|
const url = response?.notification.request.content.data?.url;
|
||||||
|
if (url) {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [router]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
@@ -176,7 +196,7 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<JotaiProvider>
|
<JotaiProvider store={jotaiStore}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<Layout />
|
<Layout />
|
||||||
@@ -187,11 +207,29 @@ export default function RootLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up online manager for network-aware query behavior
|
||||||
|
onlineManager.setEventListener((setOnline) => {
|
||||||
|
return NetInfo.addEventListener((state) => {
|
||||||
|
setOnline(!!state.isConnected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30000, // 30 seconds - data is fresh
|
staleTime: 0, // Always stale - triggers background refetch on mount
|
||||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence
|
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for offline
|
||||||
|
networkMode: "offlineFirst", // Return cache first, refetch if online
|
||||||
|
refetchOnMount: true, // Refetch when component mounts
|
||||||
|
refetchOnReconnect: true, // Refetch when network reconnects
|
||||||
|
refetchOnWindowFocus: false, // Not needed for mobile
|
||||||
|
retry: (failureCount) => {
|
||||||
|
if (!onlineManager.isOnline()) return false;
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
networkMode: "online", // Only run mutations when online
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -210,6 +248,12 @@ function Layout() {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const _segments = useSegments();
|
const _segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Enable TV menu key interception so React Native handles it instead of tvOS
|
||||||
|
useEffect(() => {
|
||||||
|
enableTVMenuKeyInterception();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
@@ -231,22 +275,19 @@ function Layout() {
|
|||||||
deviceId: getOrSetDeviceId(),
|
deviceId: getOrSetDeviceId(),
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
})
|
})
|
||||||
.then((_) => console.log("Posted expo push token"))
|
|
||||||
.catch((_) =>
|
.catch((_) =>
|
||||||
writeErrorLog("Failed to push expo push token to plugin"),
|
writeErrorLog("Failed to push expo push token to plugin"),
|
||||||
);
|
);
|
||||||
} else console.log("No token available");
|
}
|
||||||
}, [api, expoPushToken, user]);
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
const registerNotifications = useCallback(async () => {
|
const registerNotifications = useCallback(async () => {
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
console.log("Setting android notification channel 'default'");
|
|
||||||
await Notifications?.setNotificationChannelAsync("default", {
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
name: "default",
|
name: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create dedicated channel for download notifications
|
// Create dedicated channel for download notifications
|
||||||
console.log("Setting android notification channel 'downloads'");
|
|
||||||
await Notifications?.setNotificationChannelAsync("downloads", {
|
await Notifications?.setNotificationChannelAsync("downloads", {
|
||||||
name: "Downloads",
|
name: "Downloads",
|
||||||
importance: Notifications.AndroidImportance.DEFAULT,
|
importance: Notifications.AndroidImportance.DEFAULT,
|
||||||
@@ -302,9 +343,6 @@ function Layout() {
|
|||||||
responseListener.current =
|
responseListener.current =
|
||||||
Notifications?.addNotificationResponseReceivedListener(
|
Notifications?.addNotificationResponseReceivedListener(
|
||||||
(response: NotificationResponse) => {
|
(response: NotificationResponse) => {
|
||||||
// redirect if internal notification
|
|
||||||
redirect(response?.notification);
|
|
||||||
|
|
||||||
// Currently the notifications supported by the plugin will send data for deep links.
|
// Currently the notifications supported by the plugin will send data for deep links.
|
||||||
const { title, data } = response.notification.request.content;
|
const { title, data } = response.notification.request.content;
|
||||||
writeInfoLog(`Notification ${title} opened`, data);
|
writeInfoLog(`Notification ${title} opened`, data);
|
||||||
@@ -364,75 +402,145 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<NetworkStatusProvider>
|
<InactivityProvider>
|
||||||
<PlaySettingsProvider>
|
<ServerUrlProvider>
|
||||||
<LogProvider>
|
<NetworkStatusProvider>
|
||||||
<WebSocketProvider>
|
<PlaySettingsProvider>
|
||||||
<DownloadProvider>
|
<LogProvider>
|
||||||
<MusicPlayerProvider>
|
<WebSocketProvider>
|
||||||
<GlobalModalProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<MusicPlayerProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<GlobalModalProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<BottomSheetModalProvider>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<IntroSheetProvider>
|
||||||
<Stack.Screen
|
<ThemeProvider value={DarkTheme}>
|
||||||
name='(auth)/(tabs)'
|
<SystemBars style='light' hidden={false} />
|
||||||
options={{
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
title: "",
|
name='(auth)/(tabs)'
|
||||||
header: () => null,
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
title: "",
|
||||||
<Stack.Screen
|
header: () => null,
|
||||||
name='(auth)/player'
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
title: "",
|
name='(auth)/player'
|
||||||
header: () => null,
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
title: "",
|
||||||
<Stack.Screen
|
header: () => null,
|
||||||
name='(auth)/now-playing'
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
presentation: "modal",
|
name='(auth)/now-playing'
|
||||||
gestureEnabled: true,
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
presentation: "modal",
|
||||||
<Stack.Screen
|
gestureEnabled: true,
|
||||||
name='login'
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: true,
|
<Stack.Screen
|
||||||
title: "",
|
name='login'
|
||||||
headerTransparent: Platform.OS === "ios",
|
options={{
|
||||||
}}
|
headerShown: true,
|
||||||
/>
|
title: "",
|
||||||
<Stack.Screen name='+not-found' />
|
headerTransparent: Platform.OS === "ios",
|
||||||
</Stack>
|
}}
|
||||||
<Toaster
|
/>
|
||||||
duration={4000}
|
<Stack.Screen name='+not-found' />
|
||||||
toastOptions={{
|
<Stack.Screen
|
||||||
style: {
|
name='(auth)/tv-option-modal'
|
||||||
backgroundColor: "#262626",
|
options={{
|
||||||
borderColor: "#363639",
|
headerShown: false,
|
||||||
borderWidth: 1,
|
presentation: "transparentModal",
|
||||||
},
|
animation: "fade",
|
||||||
titleStyle: {
|
}}
|
||||||
color: "white",
|
/>
|
||||||
},
|
<Stack.Screen
|
||||||
}}
|
name='(auth)/tv-subtitle-modal'
|
||||||
closeButton
|
options={{
|
||||||
/>
|
headerShown: false,
|
||||||
<GlobalModal />
|
presentation: "transparentModal",
|
||||||
</ThemeProvider>
|
animation: "fade",
|
||||||
</BottomSheetModalProvider>
|
}}
|
||||||
</GlobalModalProvider>
|
/>
|
||||||
</MusicPlayerProvider>
|
<Stack.Screen
|
||||||
</DownloadProvider>
|
name='(auth)/tv-request-modal'
|
||||||
</WebSocketProvider>
|
options={{
|
||||||
</LogProvider>
|
headerShown: false,
|
||||||
</PlaySettingsProvider>
|
presentation: "transparentModal",
|
||||||
</NetworkStatusProvider>
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-season-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-series-season-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-action-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-user-switch-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
{!Platform.isTV && <GlobalModal />}
|
||||||
|
</ThemeProvider>
|
||||||
|
</IntroSheetProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</GlobalModalProvider>
|
||||||
|
</MusicPlayerProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</NetworkStatusProvider>
|
||||||
|
</ServerUrlProvider>
|
||||||
|
</InactivityProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
550
app/login.tsx
@@ -1,547 +1,13 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Platform } from "react-native";
|
||||||
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { Login } from "@/components/login/Login";
|
||||||
import { Image } from "expo-image";
|
import { TVLogin } from "@/components/login/TVLogin";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Keyboard,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const LoginPage: React.FC = () => {
|
||||||
username: z.string().min(1, t("login.username_required")),
|
if (Platform.isTV) {
|
||||||
});
|
return <TVLogin />;
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
|
||||||
useJellyfin();
|
|
||||||
|
|
||||||
const {
|
|
||||||
apiUrl: _apiUrl,
|
|
||||||
username: _username,
|
|
||||||
password: _password,
|
|
||||||
} = params as { apiUrl: string; username: string; password: string };
|
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
|
||||||
const [serverName, setServerName] = useState<string>("");
|
|
||||||
const [credentials, setCredentials] = useState<{
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}>({
|
|
||||||
username: _username || "",
|
|
||||||
password: _password || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A way to auto login based on a link
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (_apiUrl) {
|
|
||||||
await setServer({
|
|
||||||
address: _apiUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for server setup and state updates to complete
|
|
||||||
setTimeout(() => {
|
|
||||||
if (_username && _password) {
|
|
||||||
setCredentials({ username: _username, password: _password });
|
|
||||||
login(_username, _password);
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [_apiUrl, _username, _password]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerTitle: serverName,
|
|
||||||
headerLeft: () =>
|
|
||||||
api?.basePath ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeServer();
|
|
||||||
}}
|
|
||||||
className='flex flex-row items-center pr-2 pl-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
|
||||||
<Text className=' ml-1 text-purple-600'>
|
|
||||||
{t("login.change_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : null,
|
|
||||||
});
|
|
||||||
}, [serverName, navigation, api?.basePath]);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
Keyboard.dismiss();
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
|
||||||
if (result.success) {
|
|
||||||
await login(credentials.username, credentials.password);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
|
||||||
} else {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.connection_failed"),
|
|
||||||
t("login.an_unexpected_error_occured"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
|
||||||
*
|
|
||||||
* This function attempts to connect to a Jellyfin server using the provided URL.
|
|
||||||
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
|
|
||||||
*
|
|
||||||
* @param {string} url - The base URL of the Jellyfin server to check.
|
|
||||||
* @returns {Promise<string | undefined>} A Promise that resolves to:
|
|
||||||
* - The full URL (including protocol) if a valid Jellyfin server is found.
|
|
||||||
* - undefined if no valid server is found at the given URL.
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
|
||||||
* - Logs errors and timeout information to the console.
|
|
||||||
*/
|
|
||||||
const checkUrl = useCallback(async (url: string) => {
|
|
||||||
setLoadingServerCheck(true);
|
|
||||||
const baseUrl = url.replace(/^https?:\/\//i, "");
|
|
||||||
const protocols = ["https", "http"];
|
|
||||||
try {
|
|
||||||
return checkHttp(baseUrl, protocols);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message === "Server too old") {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
} finally {
|
|
||||||
setLoadingServerCheck(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function checkHttp(baseUrl: string, protocols: string[]) {
|
|
||||||
for (const protocol of protocols) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${protocol}://${baseUrl}/System/Info/Public`,
|
|
||||||
{
|
|
||||||
mode: "cors",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
|
||||||
const serverVersion = data.Version?.split(".");
|
|
||||||
if (serverVersion && +serverVersion[0] <= 10) {
|
|
||||||
if (+serverVersion[1] < 10) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.too_old_server_text"),
|
|
||||||
t("login.too_old_server_description"),
|
|
||||||
);
|
|
||||||
throw new Error("Server too old");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setServerName(data.ServerName || "");
|
|
||||||
return `${protocol}://${baseUrl}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message === "Server too old") {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
|
||||||
*
|
|
||||||
* This function trims the input URL, checks its validity using the `checkUrl` function,
|
|
||||||
* and sets the server address if a valid connection is established.
|
|
||||||
*
|
|
||||||
* @param {string} url - The URL of the Jellyfin server to connect to.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Calls `checkUrl` to validate the server URL.
|
|
||||||
* - Shows an alert if the connection fails.
|
|
||||||
* - Sets the server address using `setServer` if the connection is successful.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
const handleConnect = useCallback(async (url: string) => {
|
|
||||||
url = url.trim().replace(/\/$/, "");
|
|
||||||
try {
|
|
||||||
const result = await checkUrl(url);
|
|
||||||
if (result === undefined) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.connection_failed"),
|
|
||||||
t("login.could_not_connect_to_server"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await setServer({ address: result });
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
return <Login />;
|
||||||
try {
|
|
||||||
const code = await initiateQuickConnect();
|
|
||||||
if (code) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.quick_connect"),
|
|
||||||
t("login.enter_code_to_login", { code: code }),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("login.got_it"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.error_title"),
|
|
||||||
t("login.failed_to_initiate_quick_connect"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return Platform.isTV ? (
|
|
||||||
// TV layout
|
|
||||||
<SafeAreaView className='flex-1 bg-black'>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
{api?.basePath ? (
|
|
||||||
// ------------ Username/Password view ------------
|
|
||||||
<View className='flex-1 items-center justify-center'>
|
|
||||||
{/* Safe centered column with max width so TV doesn’t stretch too far */}
|
|
||||||
<View className='w-[92%] max-w-[900px] px-2 -mt-12'>
|
|
||||||
<Text className='text-3xl font-bold text-white mb-1'>
|
|
||||||
{serverName ? (
|
|
||||||
<>
|
|
||||||
{`${t("login.login_to_title")} `}
|
|
||||||
<Text className='text-purple-500'>{serverName}</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t("login.login_title")
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-xs text-neutral-400 mb-6'>
|
|
||||||
{api.basePath}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Username */}
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.username_placeholder")}
|
|
||||||
onChangeText={(text: string) =>
|
|
||||||
setCredentials((prev) => ({ ...prev, username: text }))
|
|
||||||
}
|
|
||||||
onEndEditing={(e) => {
|
|
||||||
const newValue = e.nativeEvent.text;
|
|
||||||
if (newValue && newValue !== credentials.username) {
|
|
||||||
setCredentials((prev) => ({ ...prev, username: newValue }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={credentials.username}
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='oneTimeCode'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
extraClassName='mb-4'
|
|
||||||
autoFocus={false}
|
|
||||||
blurOnSubmit={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.password_placeholder")}
|
|
||||||
onChangeText={(text: string) =>
|
|
||||||
setCredentials((prev) => ({ ...prev, password: text }))
|
|
||||||
}
|
|
||||||
onEndEditing={(e) => {
|
|
||||||
const newValue = e.nativeEvent.text;
|
|
||||||
if (newValue && newValue !== credentials.password) {
|
|
||||||
setCredentials((prev) => ({ ...prev, password: newValue }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={credentials.password}
|
|
||||||
secureTextEntry
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='password'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
extraClassName='mb-4'
|
|
||||||
autoFocus={false}
|
|
||||||
blurOnSubmit={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View className='mt-4'>
|
|
||||||
<Button
|
|
||||||
onPress={handleLogin}
|
|
||||||
disabled={!credentials.username.trim()}
|
|
||||||
>
|
|
||||||
{t("login.login_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
<View className='mt-3'>
|
|
||||||
<Button
|
|
||||||
onPress={handleQuickConnect}
|
|
||||||
className='bg-neutral-800 border border-neutral-700'
|
|
||||||
>
|
|
||||||
{t("login.quick_connect")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
// ------------ Server connect view ------------
|
|
||||||
<View className='flex-1 items-center justify-center'>
|
|
||||||
<View className='w-[92%] max-w-[900px] -mt-2'>
|
|
||||||
<View className='items-center mb-1'>
|
|
||||||
<Image
|
|
||||||
source={require("@/assets/images/icon-ios-plain.png")}
|
|
||||||
style={{ width: 110, height: 110 }}
|
|
||||||
contentFit='contain'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className='text-white text-4xl font-bold text-center'>
|
|
||||||
Streamyfin
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-400 text-base text-left mt-2 mb-1'>
|
|
||||||
{t("server.enter_url_to_jellyfin_server")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Full-width Input with clear focus ring */}
|
|
||||||
<Input
|
|
||||||
aria-label='Server URL'
|
|
||||||
placeholder={t("server.server_url_placeholder")}
|
|
||||||
onChangeText={setServerURL}
|
|
||||||
value={serverURL}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
maxLength={500}
|
|
||||||
autoFocus={false}
|
|
||||||
blurOnSubmit={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Full-width primary button */}
|
|
||||||
<View className='mt-4'>
|
|
||||||
<Button
|
|
||||||
onPress={async () => {
|
|
||||||
await handleConnect(serverURL);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("server.connect_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Lists stay full width but inside max width container */}
|
|
||||||
<View className='mt-2'>
|
|
||||||
<JellyfinServerDiscovery
|
|
||||||
onServerSelect={async (server: any) => {
|
|
||||||
setServerURL(server.address);
|
|
||||||
if (server.serverName) setServerName(server.serverName);
|
|
||||||
await handleConnect(server.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PreviousServersList
|
|
||||||
onServerSelect={async (s: any) => {
|
|
||||||
await handleConnect(s.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
|
||||||
) : (
|
|
||||||
// Mobile layout
|
|
||||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
{api?.basePath ? (
|
|
||||||
<View className='flex flex-col flex-1 justify-center'>
|
|
||||||
<View className='px-4 w-full'>
|
|
||||||
<View className='flex flex-col space-y-2'>
|
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
|
||||||
{serverName ? (
|
|
||||||
<>
|
|
||||||
{`${t("login.login_to_title")} `}
|
|
||||||
<Text className='text-purple-600'>{serverName}</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t("login.login_title")
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.username_placeholder")}
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials((prev) => ({ ...prev, username: text }))
|
|
||||||
}
|
|
||||||
onEndEditing={(e) => {
|
|
||||||
const newValue = e.nativeEvent.text;
|
|
||||||
if (newValue && newValue !== credentials.username) {
|
|
||||||
setCredentials((prev) => ({
|
|
||||||
...prev,
|
|
||||||
username: newValue,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={credentials.username}
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
// Changed from username to oneTimeCode because it is a known issue in RN
|
|
||||||
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
|
||||||
textContentType='oneTimeCode'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.password_placeholder")}
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials((prev) => ({ ...prev, password: text }))
|
|
||||||
}
|
|
||||||
onEndEditing={(e) => {
|
|
||||||
const newValue = e.nativeEvent.text;
|
|
||||||
if (newValue && newValue !== credentials.password) {
|
|
||||||
setCredentials((prev) => ({
|
|
||||||
...prev,
|
|
||||||
password: newValue,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={credentials.password}
|
|
||||||
secureTextEntry
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='password'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<View className='flex flex-row items-center justify-between'>
|
|
||||||
<Button
|
|
||||||
onPress={handleLogin}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!credentials.username.trim()}
|
|
||||||
className='flex-1 mr-2'
|
|
||||||
>
|
|
||||||
{t("login.login_button")}
|
|
||||||
</Button>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleQuickConnect}
|
|
||||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='cellphone-lock'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
|
||||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
|
||||||
<Image
|
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
marginLeft: -23,
|
|
||||||
marginBottom: -20,
|
|
||||||
}}
|
|
||||||
source={require("@/assets/images/icon-ios-plain.png")}
|
|
||||||
/>
|
|
||||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
|
||||||
<Text className='text-neutral-500'>
|
|
||||||
{t("server.enter_url_to_jellyfin_server")}
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
aria-label='Server URL'
|
|
||||||
placeholder={t("server.server_url_placeholder")}
|
|
||||||
onChangeText={setServerURL}
|
|
||||||
value={serverURL}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
loading={loadingServerCheck}
|
|
||||||
disabled={loadingServerCheck}
|
|
||||||
onPress={async () => {
|
|
||||||
await handleConnect(serverURL);
|
|
||||||
}}
|
|
||||||
className='w-full grow'
|
|
||||||
>
|
|
||||||
{t("server.connect_button")}
|
|
||||||
</Button>
|
|
||||||
<JellyfinServerDiscovery
|
|
||||||
onServerSelect={async (server) => {
|
|
||||||
setServerURL(server.address);
|
|
||||||
if (server.serverName) {
|
|
||||||
setServerName(server.serverName);
|
|
||||||
}
|
|
||||||
await handleConnect(server.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PreviousServersList
|
|
||||||
onServerSelect={async (s) => {
|
|
||||||
await handleConnect(s.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Login;
|
export default LoginPage;
|
||||||
|
|||||||
251
app/tv-account-action-modal.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
TVFocusGuideView,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
// Action card component
|
||||||
|
const TVAccountActionCard: React.FC<{
|
||||||
|
label: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
const isDestructive = variant === "destructive";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
flexDirection: "row",
|
||||||
|
height: 60,
|
||||||
|
backgroundColor: focused
|
||||||
|
? isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff"
|
||||||
|
: isDestructive
|
||||||
|
? "rgba(239, 68, 68, 0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={22}
|
||||||
|
color={
|
||||||
|
focused
|
||||||
|
? isDestructive
|
||||||
|
? "#fff"
|
||||||
|
: "#000"
|
||||||
|
: isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused
|
||||||
|
? isDestructive
|
||||||
|
? "#fff"
|
||||||
|
: "#000"
|
||||||
|
: isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TVAccountActionModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvAccountActionModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
store.set(tvAccountActionModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
modalState?.onLogin();
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
modalState?.onDelete();
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
opacity: overlayOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
transform: [{ translateY: sheetTranslateY }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Account username as title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modalState.account.username}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Server name as subtitle */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modalState.server.name || modalState.server.address}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Horizontal options */}
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVAccountActionCard
|
||||||
|
label={t("common.login")}
|
||||||
|
icon='log-in-outline'
|
||||||
|
hasTVPreferredFocus
|
||||||
|
onPress={handleLogin}
|
||||||
|
/>
|
||||||
|
<TVAccountActionCard
|
||||||
|
label={t("common.delete")}
|
||||||
|
icon='trash-outline'
|
||||||
|
variant='destructive'
|
||||||
|
onPress={handleDelete}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
app/tv-account-select-modal.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
TVFocusGuideView,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVUserCard } from "@/components/tv/TVUserCard";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
// Action button for bottom sheet
|
||||||
|
const TVAccountSelectAction: React.FC<{
|
||||||
|
label: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
onPress: () => void;
|
||||||
|
}> = ({ label, icon, variant = "default", onPress }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
const isDestructive = variant === "destructive";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: focused
|
||||||
|
? isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff"
|
||||||
|
: isDestructive
|
||||||
|
? "rgba(239, 68, 68, 0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
minHeight: 72,
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={22}
|
||||||
|
color={
|
||||||
|
focused
|
||||||
|
? isDestructive
|
||||||
|
? "#fff"
|
||||||
|
: "#000"
|
||||||
|
: isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused
|
||||||
|
? isDestructive
|
||||||
|
? "#fff"
|
||||||
|
: "#000"
|
||||||
|
: isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TVAccountSelectModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvAccountSelectModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(300);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
store.set(tvAccountSelectModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
opacity: overlayOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
transform: [{ translateY: sheetTranslateY }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("server.select_account")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Server name as subtitle */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modalState.server.name || modalState.server.address}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* All options in single horizontal row */}
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 20,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modalState.server.accounts?.map((account, index) => (
|
||||||
|
<TVUserCard
|
||||||
|
key={account.userId}
|
||||||
|
username={account.username}
|
||||||
|
securityType={account.securityType}
|
||||||
|
onPress={() => {
|
||||||
|
modalState.onAccountAction(account);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<TVAccountSelectAction
|
||||||
|
label={t("server.add_account")}
|
||||||
|
icon='person-add-outline'
|
||||||
|
onPress={() => {
|
||||||
|
modalState.onAddAccount();
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TVAccountSelectAction
|
||||||
|
label={t("server.remove_server")}
|
||||||
|
icon='trash-outline'
|
||||||
|
variant='destructive'
|
||||||
|
onPress={() => {
|
||||||
|
modalState.onDeleteServer();
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
assets/images/icon-tvos-small-2x.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
assets/images/icon-tvos-small.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/images/icon-tvos-topshelf-2x.png
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
assets/images/icon-tvos-topshelf-wide-2x.png
Normal file
|
After Width: | Height: | Size: 417 KiB |
BIN
assets/images/icon-tvos-topshelf-wide.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
assets/images/icon-tvos-topshelf.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/images/icon-tvos.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
|||||||
223
components/AccountsSheet.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Swipeable } from "react-native-gesture-handler";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import {
|
||||||
|
deleteAccountCredential,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
interface AccountsSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
server: SavedServer | null;
|
||||||
|
onAccountSelect: (account: SavedServerAccount) => void;
|
||||||
|
onAddAccount: () => void;
|
||||||
|
onAccountDeleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccountsSheet: React.FC<AccountsSheetProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
server,
|
||||||
|
onAccountSelect,
|
||||||
|
onAddAccount,
|
||||||
|
onAccountDeleted,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
const isAndroid = Platform.OS === "android";
|
||||||
|
const snapPoints = useMemo(
|
||||||
|
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||||
|
[isAndroid],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteAccount = async (account: SavedServerAccount) => {
|
||||||
|
if (!server) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("server.remove_saved_login"),
|
||||||
|
t("server.remove_account_description", { username: account.username }),
|
||||||
|
[
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("common.remove"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteAccountCredential(server.address, account.userId);
|
||||||
|
onAccountDeleted?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecurityIcon = (
|
||||||
|
securityType: SavedServerAccount["securityType"],
|
||||||
|
): keyof typeof Ionicons.glyphMap => {
|
||||||
|
switch (securityType) {
|
||||||
|
case "pin":
|
||||||
|
return "keypad";
|
||||||
|
case "password":
|
||||||
|
return "lock-closed";
|
||||||
|
default:
|
||||||
|
return "key";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRightActions = (account: SavedServerAccount) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleDeleteAccount(account)}
|
||||||
|
className='bg-red-600 justify-center items-center px-5'
|
||||||
|
>
|
||||||
|
<Ionicons name='trash' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!server) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
{/* Header */}
|
||||||
|
<View className='mb-4'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("server.select_account")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 mt-1'>
|
||||||
|
{server.name || server.address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Account List */}
|
||||||
|
<View className='bg-neutral-800 rounded-xl overflow-hidden mb-4'>
|
||||||
|
{server.accounts.map((account, index) => (
|
||||||
|
<Swipeable
|
||||||
|
key={account.userId}
|
||||||
|
renderRightActions={() => renderRightActions(account)}
|
||||||
|
overshootRight={false}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onAccountSelect(account);
|
||||||
|
}}
|
||||||
|
className={`flex-row items-center p-4 bg-neutral-800 ${
|
||||||
|
index < server.accounts.length - 1
|
||||||
|
? "border-b border-neutral-700"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<View className='w-10 h-10 bg-neutral-700 rounded-full items-center justify-center mr-3'>
|
||||||
|
<Ionicons name='person' size={20} color='white' />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Account Info */}
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='text-neutral-100 font-medium'>
|
||||||
|
{account.username}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{account.securityType === "none"
|
||||||
|
? t("save_account.no_protection")
|
||||||
|
: account.securityType === "pin"
|
||||||
|
? t("save_account.pin_code")
|
||||||
|
: t("save_account.password")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Security Icon */}
|
||||||
|
<Ionicons
|
||||||
|
name={getSecurityIcon(account.securityType)}
|
||||||
|
size={18}
|
||||||
|
color={Colors.primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Swipeable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
<Text className='text-xs text-neutral-500 mb-4 ml-1'>
|
||||||
|
{t("server.swipe_to_remove")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Add Account Button */}
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onAddAccount();
|
||||||
|
}}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("server.add_account")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { View, type ViewProps } from "react-native";
|
import { BlurView } from "expo-blur";
|
||||||
|
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||||
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -13,16 +16,11 @@ export const Badge: React.FC<Props> = ({
|
|||||||
variant = "purple",
|
variant = "purple",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
const typography = useScaledTVTypography();
|
||||||
<View
|
|
||||||
{...props}
|
const content = (
|
||||||
className={`
|
<View style={styles.content}>
|
||||||
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
|
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||||
${variant === "purple" && "bg-purple-600"}
|
|
||||||
${variant === "gray" && "bg-neutral-800"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
|
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-xs
|
text-xs
|
||||||
@@ -33,4 +31,104 @@ export const Badge: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<View {...props} style={[styles.container, props.style]}>
|
||||||
|
<GlassEffectView style={{ borderRadius: 100 }}>
|
||||||
|
{content}
|
||||||
|
</GlassEffectView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On TV, use BlurView for consistent styling
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<BlurView
|
||||||
|
intensity={10}
|
||||||
|
tint='light'
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
flexShrink: 1,
|
||||||
|
flexGrow: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
|
},
|
||||||
|
props.style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#E5E7EB",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...props}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 4,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexGrow: 0,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: variant === "purple" ? "#9333ea" : "#262626",
|
||||||
|
},
|
||||||
|
props.style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{iconLeft && <View style={{ marginRight: 4 }}>{iconLeft}</View>}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
overflow: "hidden",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
flexShrink: 1,
|
||||||
|
flexGrow: 0,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 50,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
iconLeft: {
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||