mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-30 15:08:25 +00:00
Compare commits
375 Commits
sonarqube
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
24d04c1003 | ||
|
|
7da52441ab | ||
|
|
70268e6120 | ||
|
|
96fbb9fe1f | ||
|
|
3b104b91fc | ||
|
|
e4134d6f9a | ||
|
|
5b2e7b3883 | ||
|
|
1fde3c82a3 | ||
|
|
054fb05651 | ||
|
|
a2058a8009 | ||
|
|
d22827bc9b | ||
|
|
4121502bfe | ||
|
|
b6e59aab01 | ||
|
|
ab3465aec5 | ||
|
|
b1da9f8777 | ||
|
|
36d24176ae | ||
|
|
bfdc2c053b | ||
|
|
245c9597c4 | ||
|
|
966a8e8f24 | ||
|
|
f941c88457 | ||
|
|
bd4e5bb70a | ||
|
|
9334263414 | ||
|
|
4ae3c44d02 | ||
|
|
4fb3fb195c | ||
|
|
e8089cfd20 | ||
|
|
039bf9729a | ||
|
|
3ff7c47b7f | ||
|
|
1d8d92175a | ||
|
|
60b0040681 | ||
|
|
9cd55cf544 | ||
|
|
090e0cb170 | ||
|
|
85d707ef45 | ||
|
|
792eef20a9 | ||
|
|
6487c8b5a1 | ||
|
|
baa96d222f | ||
|
|
74d86b5d12 | ||
|
|
d1795c9df8 | ||
|
|
149609f46e | ||
|
|
cf269ba83e | ||
|
|
24d5fdefdf | ||
|
|
c05cef295e | ||
|
|
3c57829360 | ||
|
|
06349a4319 | ||
|
|
55ac9ae9d4 | ||
|
|
c8bdcc4df0 | ||
|
|
e7013edd84 | ||
|
|
991b45de06 | ||
|
|
97fe899cb0 | ||
|
|
86d7642dca | ||
|
|
631a5ef94e | ||
|
|
8b8b928837 | ||
|
|
56a3c62ed2 | ||
|
|
82683407da | ||
|
|
7b146e30bd | ||
|
|
5f48bec0f2 | ||
|
|
94362169b6 | ||
|
|
8aefdac50f | ||
|
|
665a79924a | ||
|
|
b9ddcf8404 | ||
|
|
64ffc8db8b | ||
|
|
2a61124a0d | ||
|
|
36178c2082 | ||
|
|
e1c69a9ec9 | ||
|
|
01110b8d13 | ||
|
|
21034f5671 | ||
|
|
1439bcee0d | ||
|
|
9a906e6d39 | ||
|
|
48de7b7c6d | ||
|
|
85e5c25206 | ||
|
|
3dc84818e8 | ||
|
|
18102a3045 | ||
|
|
2be78a232c | ||
|
|
30dc3980e3 | ||
|
|
f7da29b9c9 | ||
|
|
7a5f0b52b6 | ||
|
|
62dfe7c9e1 | ||
|
|
50d559d528 | ||
|
|
38aba3d67a | ||
|
|
5765793d79 | ||
|
|
222ba13529 | ||
|
|
de6c2072c9 | ||
|
|
76ec8e0e46 | ||
|
|
389d9e2d31 | ||
|
|
485dc6eeac | ||
|
|
154788cf91 | ||
|
|
3e181eca72 | ||
|
|
f5b9e03dd9 | ||
|
|
196f91400b | ||
|
|
51a14c6058 | ||
|
|
5432476ca1 | ||
|
|
9d9ec974ff | ||
|
|
0dadfd3d90 | ||
|
|
96d6220f5e | ||
|
|
b847baa314 | ||
|
|
a4cce27737 | ||
|
|
ce37351099 | ||
|
|
e4e1e556bf | ||
|
|
1ff723b29f | ||
|
|
11d35c846d | ||
|
|
ce82f3044b | ||
|
|
df638dae28 | ||
|
|
9a5e49ae16 | ||
|
|
4f2120f85d | ||
|
|
8eeea35441 | ||
|
|
781464b768 | ||
|
|
61d60c2e74 |
103
.claude/agents/tv-validator.md
Normal file
103
.claude/agents/tv-validator.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
name: tv-validator
|
||||||
|
description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements.
|
||||||
|
tools: Read, Glob, Grep
|
||||||
|
model: haiku
|
||||||
|
color: blue
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations.
|
||||||
|
|
||||||
|
## Critical Rules to Check
|
||||||
|
|
||||||
|
### 1. No .tv.tsx File Suffix
|
||||||
|
The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it.
|
||||||
|
|
||||||
|
**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution
|
||||||
|
**Correct**: Use `Platform.isTV` conditional rendering in the main file:
|
||||||
|
```typescript
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVMyComponent />;
|
||||||
|
}
|
||||||
|
return <MyComponent />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. No FlashList on TV
|
||||||
|
FlashList has focus issues on TV. Use FlatList instead.
|
||||||
|
|
||||||
|
**Violation**: `<FlashList` in TV code paths
|
||||||
|
**Correct**:
|
||||||
|
```typescript
|
||||||
|
{Platform.isTV ? (
|
||||||
|
<FlatList removeClippedSubviews={false} ... />
|
||||||
|
) : (
|
||||||
|
<FlashList ... />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Modal Pattern
|
||||||
|
Never use overlay/absolute-positioned modals on TV. They break back button handling.
|
||||||
|
|
||||||
|
**Violation**: `position: "absolute"` or `Modal` component for TV overlays
|
||||||
|
**Correct**: Use navigation-based pattern:
|
||||||
|
- Create Jotai atom for state
|
||||||
|
- Hook that sets atom and calls `router.push()`
|
||||||
|
- Page in `app/(auth)/` that reads atom
|
||||||
|
- `Stack.Screen` with `presentation: "transparentModal"`
|
||||||
|
|
||||||
|
### 4. Typography
|
||||||
|
All TV text must use `TVTypography` component.
|
||||||
|
|
||||||
|
**Violation**: Raw `<Text>` in TV components
|
||||||
|
**Correct**: `<TVTypography variant="title">...</TVTypography>`
|
||||||
|
|
||||||
|
### 5. No Purple Accent Colors
|
||||||
|
TV uses white for focus states, not purple.
|
||||||
|
|
||||||
|
**Violation**: Purple/violet colors in TV focused states
|
||||||
|
**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds
|
||||||
|
|
||||||
|
### 6. Focus Handling
|
||||||
|
- Only ONE element should have `hasTVPreferredFocus={true}`
|
||||||
|
- Focusable items need `disabled={isModalOpen}` when overlays are visible
|
||||||
|
- Use `onFocus`/`onBlur` with scale animations
|
||||||
|
- Add padding for scale animations (focus scale clips without it)
|
||||||
|
|
||||||
|
### 7. List Configuration
|
||||||
|
TV lists need:
|
||||||
|
- `removeClippedSubviews={false}`
|
||||||
|
- `overflow: "visible"` on containers
|
||||||
|
- Sufficient padding for focus scale animations
|
||||||
|
|
||||||
|
### 8. Horizontal Padding
|
||||||
|
Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20).
|
||||||
|
|
||||||
|
### 9. Focus Guide Navigation
|
||||||
|
For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop.
|
||||||
|
Use `useState` for refs (not `useRef`) to trigger re-renders.
|
||||||
|
|
||||||
|
## Review Process
|
||||||
|
|
||||||
|
1. Read the file(s) to review
|
||||||
|
2. Check each rule above
|
||||||
|
3. Report violations with:
|
||||||
|
- Line number
|
||||||
|
- What's wrong
|
||||||
|
- How to fix it
|
||||||
|
4. If no violations, confirm the code follows TV patterns
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
```
|
||||||
|
## TV Validation Results
|
||||||
|
|
||||||
|
### ✓ Passes
|
||||||
|
- [List of rules that pass]
|
||||||
|
|
||||||
|
### ✗ Violations
|
||||||
|
- **[Rule Name]** (line X): [Description]
|
||||||
|
Fix: [How to correct it]
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
- [Optional suggestions for improvement]
|
||||||
|
```
|
||||||
70
.claude/commands/reflect.md
Normal file
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
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
9
.claude/learned-facts/header-button-locations.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Header Button Locations
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: ui
|
||||||
|
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`, `components/common/HeaderBackButton.tsx`, `components/Chromecast.tsx`, `components/RoundButton.tsx`, `components/home/Home.tsx`, `app/(auth)/(tabs)/(home)/downloads/index.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`.
|
||||||
9
.claude/learned-facts/intro-modal-trigger-location.md
Normal file
9
.claude/learned-facts/intro-modal-trigger-location.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Intro Modal Trigger Location
|
||||||
|
|
||||||
|
**Date**: 2025-01-09
|
||||||
|
**Category**: navigation
|
||||||
|
**Key files**: `components/home/Home.tsx`, `app/(auth)/(tabs)/_layout.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation.
|
||||||
9
.claude/learned-facts/introsheet-rendering-location.md
Normal file
9
.claude/learned-facts/introsheet-rendering-location.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# IntroSheet Rendering Location
|
||||||
|
|
||||||
|
**Date**: 2025-01-09
|
||||||
|
**Category**: navigation
|
||||||
|
**Key files**: `providers/IntroSheetProvider`, `components/IntroSheet`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs.
|
||||||
9
.claude/learned-facts/macos-header-buttons-fix.md
Normal file
9
.claude/learned-facts/macos-header-buttons-fix.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# macOS Header Buttons Fix
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: ui
|
||||||
|
**Key files**: `components/common/HeaderBackButton.tsx`, `app/(auth)/(tabs)/(home)/_layout.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app.
|
||||||
9
.claude/learned-facts/mark-as-played-flow.md
Normal file
9
.claude/learned-facts/mark-as-played-flow.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Mark as Played Flow
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: state-management
|
||||||
|
**Key files**: `components/PlayedStatus.tsx`, `hooks/useMarkAsPlayed.ts`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# MPV avfoundation-composite-osd Ordering
|
||||||
|
|
||||||
|
**Date**: 2026-01-22
|
||||||
|
**Category**: native-modules
|
||||||
|
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support).
|
||||||
9
.claude/learned-facts/mpv-tvos-player-exit-freeze.md
Normal file
9
.claude/learned-facts/mpv-tvos-player-exit-freeze.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# MPV tvOS Player Exit Freeze
|
||||||
|
|
||||||
|
**Date**: 2026-01-22
|
||||||
|
**Category**: native-modules
|
||||||
|
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Native Bottom Tabs + useRouter Conflict
|
||||||
|
|
||||||
|
**Date**: 2025-01-09
|
||||||
|
**Category**: navigation
|
||||||
|
**Key files**: `providers/`, `app/_layout.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead.
|
||||||
9
.claude/learned-facts/native-swiftui-view-sizing.md
Normal file
9
.claude/learned-facts/native-swiftui-view-sizing.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Native SwiftUI View Sizing
|
||||||
|
|
||||||
|
**Date**: 2026-01-25
|
||||||
|
**Category**: native-modules
|
||||||
|
**Key files**: `modules/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Platform-Specific File Suffix (.tv.tsx) Does NOT Work
|
||||||
|
|
||||||
|
**Date**: 2026-01-26
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `app/`, `components/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Stack Screen Header Configuration
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: ui
|
||||||
|
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Streamystats Components Location
|
||||||
|
|
||||||
|
**Date**: 2026-01-25
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `components/home/StreamystatsRecommendations.tv.tsx`, `components/home/StreamystatsPromotedWatchlists.tv.tsx`, `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`.
|
||||||
9
.claude/learned-facts/tab-folder-naming.md
Normal file
9
.claude/learned-facts/tab-folder-naming.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Tab Folder Naming
|
||||||
|
|
||||||
|
**Date**: 2025-01-09
|
||||||
|
**Category**: navigation
|
||||||
|
**Key files**: `app/(auth)/(tabs)/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Thread-Safe State for Stop Flags
|
||||||
|
|
||||||
|
**Date**: 2026-01-22
|
||||||
|
**Category**: native-modules
|
||||||
|
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time.
|
||||||
9
.claude/learned-facts/tv-grid-layout-pattern.md
Normal file
9
.claude/learned-facts/tv-grid-layout-pattern.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# TV Grid Layout Pattern
|
||||||
|
|
||||||
|
**Date**: 2026-01-25
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `components/tv/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing.
|
||||||
9
.claude/learned-facts/tv-horizontal-padding-standard.md
Normal file
9
.claude/learned-facts/tv-horizontal-padding-standard.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# TV Horizontal Padding Standard
|
||||||
|
|
||||||
|
**Date**: 2026-01-25
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `components/tv/`, `app/(auth)/(tabs)/`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# TV Modals Must Use Navigation Pattern
|
||||||
|
|
||||||
|
**Date**: 2026-01-24
|
||||||
|
**Category**: tv
|
||||||
|
**Key files**: `hooks/useTVOptionModal.ts`, `app/(auth)/tv-option-modal.tsx`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# useNetworkAwareQueryClient Limitations
|
||||||
|
|
||||||
|
**Date**: 2026-01-10
|
||||||
|
**Category**: state-management
|
||||||
|
**Key files**: `hooks/useNetworkAwareQueryClient.ts`
|
||||||
|
|
||||||
|
## Detail
|
||||||
|
|
||||||
|
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.
|
||||||
7
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
7
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -77,13 +77,8 @@ body:
|
|||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.47.1
|
||||||
- 0.30.2
|
- 0.30.2
|
||||||
- 0.29.0
|
|
||||||
- 0.28.0
|
|
||||||
- 0.27.0
|
|
||||||
- 0.26.1
|
|
||||||
- 0.26.0
|
|
||||||
- 0.25.0
|
|
||||||
- older
|
- older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
12
.github/crowdin.yml
vendored
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
133
.github/workflows/build-apps.yml
vendored
133
.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: |
|
||||||
@@ -156,14 +180,14 @@ jobs:
|
|||||||
|
|
||||||
build-ios-phone:
|
build-ios-phone:
|
||||||
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'))
|
||||||
runs-on: macos-15
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone)
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
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') }}
|
||||||
@@ -191,6 +215,11 @@ jobs:
|
|||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
|
with:
|
||||||
|
xcode-version: "26.2"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
@@ -198,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
|
||||||
@@ -210,16 +236,73 @@ 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'))
|
||||||
# runs-on: macos-15
|
# runs-on: macos-26
|
||||||
# name: 🍎 Build iOS IPA (TV)
|
# name: 🍎 Build iOS IPA (TV)
|
||||||
# permissions:
|
# permissions:
|
||||||
# contents: read
|
# contents: read
|
||||||
@@ -254,6 +337,11 @@ jobs:
|
|||||||
# - name: 🛠️ Generate project files
|
# - name: 🛠️ Generate project files
|
||||||
# run: bun run prebuild:tv
|
# run: bun run prebuild:tv
|
||||||
#
|
#
|
||||||
|
# - name: 🔧 Setup Xcode
|
||||||
|
# uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
# with:
|
||||||
|
# xcode-version: '26.0.1'
|
||||||
|
#
|
||||||
# - name: 🏗️ Setup EAS
|
# - name: 🏗️ Setup EAS
|
||||||
# uses: expo/expo-github-action@main
|
# uses: expo/expo-github-action@main
|
||||||
# with:
|
# with:
|
||||||
@@ -261,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
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
|
||||||
|
|||||||
12
.github/workflows/ci-codeql.yml
vendored
12
.github/workflows/ci-codeql.yml
vendored
@@ -24,20 +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
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|||||||
9
.github/workflows/crowdin.yml
vendored
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 }}
|
||||||
|
|||||||
14
.github/workflows/linting.yml
vendored
14
.github/workflows/linting.yml
vendored
@@ -51,13 +51,13 @@ 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
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
@@ -69,14 +69,14 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
uses: actions/checkout@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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.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
.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
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
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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.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
|
||||||
|
|||||||
11
.gitignore
vendored
11
.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,7 +50,6 @@ npm-debug.*
|
|||||||
.idea/
|
.idea/
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
.cursor/
|
.cursor/
|
||||||
.claude/
|
|
||||||
|
|
||||||
# Environment and Configuration
|
# Environment and Configuration
|
||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
@@ -65,3 +64,11 @@ streamyfin-4fec1-firebase-adminsdk.json
|
|||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
|
/modules/sf-player/android/build
|
||||||
|
/modules/music-controls/android/build
|
||||||
|
modules/background-downloader/android/build/*
|
||||||
|
/modules/mpv-player/android/build
|
||||||
|
|
||||||
|
# ios:unsigned-build Artifacts
|
||||||
|
build/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
177
.vscode/settings.json
vendored
177
.vscode/settings.json
vendored
@@ -1,178 +1,25 @@
|
|||||||
{
|
{
|
||||||
// ==========================================
|
|
||||||
// FORMATTING & LINTING
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Biome as default formatter
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnPaste": true,
|
"editor.codeActionsOnSave": {
|
||||||
"editor.formatOnType": false,
|
"source.fixAll.biome": "explicit"
|
||||||
|
|
||||||
// Language-specific formatters
|
|
||||||
"[javascript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"[javascriptreact]": {
|
"[javascriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[jsonc]": {
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSaveMode": "file"
|
||||||
},
|
|
||||||
"[swift]": {
|
|
||||||
"editor.insertSpaces": true,
|
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TYPESCRIPT & JAVASCRIPT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// TypeScript performance optimizations
|
|
||||||
"typescript.preferences.includePackageJsonAutoImports": "auto",
|
|
||||||
"typescript.suggest.autoImports": true,
|
|
||||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
|
||||||
"typescript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"typescript.preferences.includeCompletionsForImportStatements": true,
|
|
||||||
"typescript.preferences.includeCompletionsWithSnippetText": true,
|
|
||||||
|
|
||||||
// JavaScript settings
|
|
||||||
"javascript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"javascript.suggest.autoImports": true,
|
|
||||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// REACT NATIVE & EXPO
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// File associations for React Native
|
|
||||||
"files.associations": {
|
|
||||||
"*.expo.ts": "typescript",
|
|
||||||
"*.expo.tsx": "typescriptreact",
|
|
||||||
"*.expo.js": "javascript",
|
|
||||||
"*.expo.jsx": "javascriptreact",
|
|
||||||
"metro.config.js": "javascript",
|
|
||||||
"babel.config.js": "javascript",
|
|
||||||
"app.config.js": "javascript",
|
|
||||||
"eas.json": "jsonc"
|
|
||||||
},
|
|
||||||
|
|
||||||
// React Native specific settings
|
|
||||||
"emmet.includeLanguages": {
|
|
||||||
"typescriptreact": "html",
|
|
||||||
"javascriptreact": "html"
|
|
||||||
},
|
|
||||||
"emmet.triggerExpansionOnTab": true,
|
|
||||||
|
|
||||||
// Exclude build directories from search
|
|
||||||
"search.exclude": {
|
|
||||||
"**/node_modules": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// EDITOR PERFORMANCE & UX
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Performance optimizations
|
|
||||||
"editor.largeFileOptimizations": true,
|
|
||||||
"files.watcherExclude": {
|
|
||||||
"**/.git/objects/**": true,
|
|
||||||
"**/.git/subtree-cache/**": true,
|
|
||||||
"**/node_modules/**": true,
|
|
||||||
"**/.expo/**": true,
|
|
||||||
"**/ios/**": true,
|
|
||||||
"**/android/**": true,
|
|
||||||
"**/build/**": true,
|
|
||||||
"**/dist/**": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better editor behavior
|
|
||||||
"editor.suggestSelection": "first",
|
|
||||||
"editor.quickSuggestions": {
|
|
||||||
"strings": true,
|
|
||||||
"comments": true,
|
|
||||||
"other": true
|
|
||||||
},
|
|
||||||
"editor.snippetSuggestions": "top",
|
|
||||||
"editor.tabCompletion": "on",
|
|
||||||
"editor.wordBasedSuggestions": "off",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TERMINAL & DEVELOPMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Terminal settings for Bun (Windows-specific)
|
|
||||||
"terminal.integrated.profiles.windows": {
|
|
||||||
"Command Prompt": {
|
|
||||||
"path": "C:\\Windows\\System32\\cmd.exe",
|
|
||||||
"env": {
|
|
||||||
"PATH": "${env:PATH};./node_modules/.bin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// WORKSPACE & NAVIGATION
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better workspace navigation
|
|
||||||
"explorer.fileNesting.enabled": true,
|
|
||||||
"explorer.fileNesting.expand": false,
|
|
||||||
"explorer.fileNesting.patterns": {
|
|
||||||
"*.ts": "${capture}.js",
|
|
||||||
"*.tsx": "${capture}.js",
|
|
||||||
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
|
|
||||||
"*.jsx": "${capture}.js",
|
|
||||||
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
|
|
||||||
"tsconfig.json": "tsconfig.*.json",
|
|
||||||
".env": ".env.*",
|
|
||||||
"app.json": "app.config.js,eas.json,expo-env.d.ts",
|
|
||||||
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better breadcrumbs and navigation
|
|
||||||
"breadcrumbs.enabled": true,
|
|
||||||
"outline.showVariables": true,
|
|
||||||
"outline.showConstants": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// GIT & VERSION CONTROL
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Git integration
|
|
||||||
"git.autofetch": true,
|
|
||||||
"git.enableSmartCommit": true,
|
|
||||||
"git.confirmSync": false,
|
|
||||||
"git.ignoreLimitWarning": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// CODE QUALITY & ERRORS
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better error detection
|
|
||||||
"typescript.validate.enable": true,
|
|
||||||
"javascript.validate.enable": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll": "explicit",
|
|
||||||
"source.organizeImports": "explicit"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Problem matcher for better error display
|
|
||||||
"typescript.tsc.autoDetect": "on"
|
|
||||||
}
|
}
|
||||||
|
|||||||
377
CLAUDE.md
Normal file
377
CLAUDE.md
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Learned Facts Index
|
||||||
|
|
||||||
|
IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in `.claude/learned-facts/` before relying on assumptions.
|
||||||
|
|
||||||
|
Navigation:
|
||||||
|
- `native-bottom-tabs-userouter-conflict` | useRouter() at provider level causes tab switches; use static router import
|
||||||
|
- `introsheet-rendering-location` | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks
|
||||||
|
- `intro-modal-trigger-location` | Trigger in Home.tsx, not tabs _layout.tsx
|
||||||
|
- `tab-folder-naming` | Use underscore prefix: (_home) not (home)
|
||||||
|
|
||||||
|
UI/Headers:
|
||||||
|
- `macos-header-buttons-fix` | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity
|
||||||
|
- `header-button-locations` | Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc.
|
||||||
|
- `stack-screen-header-configuration` | Sub-pages need explicit Stack.Screen with headerTransparent + back button
|
||||||
|
|
||||||
|
State/Data:
|
||||||
|
- `use-network-aware-query-client-limitations` | Object.create breaks private fields; only for invalidateQueries
|
||||||
|
- `mark-as-played-flow` | PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates
|
||||||
|
|
||||||
|
Native Modules:
|
||||||
|
- `mpv-tvos-player-exit-freeze` | mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global()
|
||||||
|
- `mpv-avfoundation-composite-osd-ordering` | MUST follow vo=avfoundation, before hwdec options
|
||||||
|
- `thread-safe-state-for-stop-flags` | Stop flags need synchronous setter (stateQueue.sync not async)
|
||||||
|
- `native-swiftui-view-sizing` | Need explicit frame + intrinsicContentSize override in ExpoView
|
||||||
|
|
||||||
|
TV Platform:
|
||||||
|
- `tv-modals-must-use-navigation-pattern` | Use atom+router.push(), never overlay/absolute modals
|
||||||
|
- `tv-grid-layout-pattern` | ScrollView+flexWrap, not FlatList numColumns
|
||||||
|
- `tv-horizontal-padding-standard` | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20
|
||||||
|
- `streamystats-components-location` | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx
|
||||||
|
- `platform-specific-file-suffix-does-not-work` | .tv.tsx doesn't work; use Platform.isTV conditional rendering
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup
|
||||||
|
bun i && bun run submodule-reload
|
||||||
|
|
||||||
|
# Development builds
|
||||||
|
bun run prebuild # Mobile prebuild
|
||||||
|
bun run ios # Run iOS
|
||||||
|
bun run android # Run Android
|
||||||
|
|
||||||
|
# TV builds (suffix with :tv)
|
||||||
|
bun run prebuild:tv
|
||||||
|
bun run ios:tv
|
||||||
|
bun run android:tv
|
||||||
|
|
||||||
|
# Code quality
|
||||||
|
bun run typecheck # TypeScript check
|
||||||
|
bun run check # BiomeJS check
|
||||||
|
bun run lint # BiomeJS lint + fix
|
||||||
|
bun run format # BiomeJS format
|
||||||
|
bun run test # Run all checks (typecheck, lint, format, doctor)
|
||||||
|
|
||||||
|
# iOS-specific
|
||||||
|
bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Bun
|
||||||
|
- **Framework**: React Native (Expo SDK 54)
|
||||||
|
- **Language**: TypeScript (strict mode)
|
||||||
|
- **State Management**: Jotai (global state atoms) + React Query (server state)
|
||||||
|
- **API**: Jellyfin SDK (`@jellyfin/sdk`)
|
||||||
|
- **Navigation**: Expo Router (file-based)
|
||||||
|
- **Linting/Formatting**: BiomeJS
|
||||||
|
- **Storage**: react-native-mmkv
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
- `app/` - Expo Router screens with file-based routing
|
||||||
|
- `components/` - Reusable UI components
|
||||||
|
- `providers/` - React Context providers
|
||||||
|
- `hooks/` - Custom React hooks
|
||||||
|
- `utils/` - Utilities including Jotai atoms
|
||||||
|
- `modules/` - Native modules (vlc-player, mpv-player, background-downloader)
|
||||||
|
- `translations/` - i18n translation files
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
**State Management**:
|
||||||
|
- Global state uses Jotai atoms in `utils/atoms/`
|
||||||
|
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
|
||||||
|
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
|
||||||
|
- Server state uses React Query with `@tanstack/react-query`
|
||||||
|
|
||||||
|
**Jellyfin API Access**:
|
||||||
|
- Use `apiAtom` from `JellyfinProvider` for authenticated API calls
|
||||||
|
- Access user via `userAtom`
|
||||||
|
- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api`
|
||||||
|
|
||||||
|
**Navigation**:
|
||||||
|
- File-based routing in `app/` directory
|
||||||
|
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
||||||
|
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
||||||
|
- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation:
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ❌ Never use this
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Offline Mode**:
|
||||||
|
- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content
|
||||||
|
- Use `useOfflineMode()` hook to check if current context is offline
|
||||||
|
- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context
|
||||||
|
|
||||||
|
**Providers** (wrapping order in `app/_layout.tsx`):
|
||||||
|
1. JotaiProvider
|
||||||
|
2. QueryClientProvider
|
||||||
|
3. JellyfinProvider (auth, API)
|
||||||
|
4. NetworkStatusProvider
|
||||||
|
5. PlaySettingsProvider
|
||||||
|
6. WebSocketProvider
|
||||||
|
7. DownloadProvider
|
||||||
|
8. MusicPlayerProvider
|
||||||
|
|
||||||
|
### Native Modules
|
||||||
|
|
||||||
|
Located in `modules/`:
|
||||||
|
- `vlc-player` - VLC video player integration
|
||||||
|
- `mpv-player` - MPV video player integration (iOS)
|
||||||
|
- `background-downloader` - Background download functionality
|
||||||
|
- `sf-player` - Swift player module
|
||||||
|
|
||||||
|
### Path Aliases
|
||||||
|
|
||||||
|
Use `@/` prefix for imports (configured in `tsconfig.json`):
|
||||||
|
```typescript
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
- Use TypeScript for all files (no .js)
|
||||||
|
- Use functional React components with hooks
|
||||||
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
- Handle both mobile and TV navigation patterns
|
||||||
|
- Use existing atoms, hooks, and utilities before creating new ones
|
||||||
|
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
|
||||||
|
|
||||||
|
## Platform Considerations
|
||||||
|
|
||||||
|
- TV version uses `:tv` suffix for scripts
|
||||||
|
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
||||||
|
- Some features disabled on TV (e.g., notifications, Chromecast)
|
||||||
|
- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays.
|
||||||
|
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
|
||||||
|
- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency.
|
||||||
|
- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
|
||||||
|
- **TV Modals**: Never use overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference.
|
||||||
|
|
||||||
|
### TV Component Rendering Pattern
|
||||||
|
|
||||||
|
**IMPORTANT**: The `.tv.tsx` file suffix 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 Selector Pattern (Dropdowns/Multi-select)
|
||||||
|
|
||||||
|
For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because:
|
||||||
|
- Horizontal scrolling is natural for TV remotes (left/right D-pad)
|
||||||
|
- Bottom sheet takes minimal screen space
|
||||||
|
- Focus-based navigation works reliably
|
||||||
|
|
||||||
|
**Key implementation details:**
|
||||||
|
|
||||||
|
1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain. Use an absolutely positioned `View` overlay instead:
|
||||||
|
```typescript
|
||||||
|
<View style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
<BlurView intensity={80} tint="dark" style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24 }}>
|
||||||
|
{/* Content */}
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Horizontal ScrollView with focusable cards**:
|
||||||
|
```typescript
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 48, paddingVertical: 10, gap: 12 }}
|
||||||
|
>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={index}
|
||||||
|
hasTVPreferredFocus={index === selectedIndex}
|
||||||
|
onPress={() => { onSelect(option.value); onClose(); }}
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`:
|
||||||
|
```typescript
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => { setFocused(true); animateTo(1.05); }}
|
||||||
|
onBlur={() => { setFocused(false); animateTo(1); }}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View style={{ transform: [{ scale }], backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)" }}>
|
||||||
|
<Text style={{ color: focused ? "#000" : "#fff" }}>{label}</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip.
|
||||||
|
|
||||||
|
**Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`
|
||||||
|
|
||||||
|
### TV Focus Management for Overlays/Modals
|
||||||
|
|
||||||
|
**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation.
|
||||||
|
|
||||||
|
**Solution**: Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Track modal state
|
||||||
|
const [openModal, setOpenModal] = useState<ModalType | null>(null);
|
||||||
|
const isModalOpen = openModal !== null;
|
||||||
|
|
||||||
|
// 2. Each focusable component accepts disabled prop
|
||||||
|
const TVFocusableButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, disabled }) => (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
hasTVPreferredFocus={isFirst && !disabled}
|
||||||
|
>
|
||||||
|
{/* content */}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Pass disabled to all background components when modal is open
|
||||||
|
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
|
||||||
|
|
||||||
|
### TV Focus Flickering Between Zones (Lists with Headers)
|
||||||
|
|
||||||
|
When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
|
||||||
|
1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check:
|
||||||
|
```typescript
|
||||||
|
{Platform.isTV ? (
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FlashList data={items} renderItem={renderItem} />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.
|
||||||
|
|
||||||
|
3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - only first filter button has preferred focus
|
||||||
|
<TVFilterButton hasTVPreferredFocus={index === 0} />
|
||||||
|
<TVFocusablePoster /> // No hasTVPreferredFocus
|
||||||
|
|
||||||
|
// ❌ Bad - both compete for focus
|
||||||
|
<TVFilterButton hasTVPreferredFocus />
|
||||||
|
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
|
||||||
|
```typescript
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Filter bar - separate from list */}
|
||||||
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||||
|
<TVFilterButton />
|
||||||
|
<TVFilterButton />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<FlatList data={items} renderItem={renderTVItem} />
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
|
||||||
|
|
||||||
|
**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.
|
||||||
|
|
||||||
|
### TV Focus Guide Navigation (Non-Adjacent Sections)
|
||||||
|
|
||||||
|
When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use `TVFocusGuideView` with the `destinations` prop:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Track destination with useState (NOT useRef - won't trigger re-renders)
|
||||||
|
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
// 2. Place invisible focus guide between sections
|
||||||
|
{firstCardRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[firstCardRef]}
|
||||||
|
style={{ height: 1, width: "100%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
// 3. Target component must use forwardRef
|
||||||
|
const MyCard = React.forwardRef<View, Props>(({ ... }, ref) => (
|
||||||
|
<Pressable ref={ref} ...>
|
||||||
|
...
|
||||||
|
</Pressable>
|
||||||
|
));
|
||||||
|
|
||||||
|
// 4. Pass state setter as callback ref to first item
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<MyCard
|
||||||
|
ref={index === 0 ? setFirstCardRef : undefined}
|
||||||
|
...
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For detailed documentation and bidirectional navigation patterns, see [docs/tv-focus-guide.md](docs/tv-focus-guide.md)**
|
||||||
|
|
||||||
|
**Reference implementation**: See `components/ItemContent.tv.tsx` for bidirectional focus navigation between playback options and cast list.
|
||||||
232
GLOBAL_MODAL_GUIDE.md
Normal file
232
GLOBAL_MODAL_GUIDE.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Global Modal System with Gorhom Bottom Sheet
|
||||||
|
|
||||||
|
This guide explains how to use the global modal system implemented in this project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system consists of three main parts:
|
||||||
|
|
||||||
|
1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state
|
||||||
|
2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level
|
||||||
|
3. **useGlobalModal** hook - Hook to interact with the modal from anywhere
|
||||||
|
|
||||||
|
## Setup (Already Configured)
|
||||||
|
|
||||||
|
The system is already integrated into your app:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In app/_layout.tsx
|
||||||
|
<BottomSheetModalProvider>
|
||||||
|
<GlobalModalProvider>
|
||||||
|
{/* Your app content */}
|
||||||
|
<GlobalModal />
|
||||||
|
</GlobalModalProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
import { View, Text } from "react-native";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-white text-2xl'>Hello from Modal!</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onPress={handleOpenModal} title="Open Modal" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Custom Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<YourCustomComponent />,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"], // Custom snap points
|
||||||
|
enablePanDownToClose: true, // Allow swipe to close
|
||||||
|
backgroundStyle: { // Custom background
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic Control
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Open modal
|
||||||
|
showModal(<Content />);
|
||||||
|
|
||||||
|
// Close modal from within the modal content
|
||||||
|
function ModalContent() {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button onPress={hideModal} title="Close" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal from outside
|
||||||
|
hideModal();
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Event Handlers or Functions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useApiCall() {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.fetch();
|
||||||
|
|
||||||
|
// Show success modal
|
||||||
|
showModal(
|
||||||
|
<SuccessMessage data={result} />
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Show error modal
|
||||||
|
showModal(
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetchData;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `useGlobalModal()`
|
||||||
|
|
||||||
|
Returns an object with the following properties:
|
||||||
|
|
||||||
|
- **`showModal(content, options?)`** - Show the modal with given content
|
||||||
|
- `content: ReactNode` - Any React component or element to render
|
||||||
|
- `options?: ModalOptions` - Optional configuration object
|
||||||
|
|
||||||
|
- **`hideModal()`** - Programmatically hide the modal
|
||||||
|
|
||||||
|
- **`isVisible: boolean`** - Current visibility state of the modal
|
||||||
|
|
||||||
|
### `ModalOptions`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ModalOptions {
|
||||||
|
enableDynamicSizing?: boolean; // Auto-size based on content (default: true)
|
||||||
|
snapPoints?: (string | number)[]; // Fixed snap points (e.g., ["50%", "90%"])
|
||||||
|
enablePanDownToClose?: boolean; // Allow swipe down to close (default: true)
|
||||||
|
backgroundStyle?: object; // Custom background styles
|
||||||
|
handleIndicatorStyle?: object; // Custom handle indicator styles
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||||
|
- Simple content modal
|
||||||
|
- Modal with custom snap points
|
||||||
|
- Complex component in modal
|
||||||
|
- Success/error modals triggered from functions
|
||||||
|
|
||||||
|
## Default Styling
|
||||||
|
|
||||||
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
enableDynamicSizing: true,
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
backgroundStyle: {
|
||||||
|
backgroundColor: "#171717", // Dark background
|
||||||
|
},
|
||||||
|
handleIndicatorStyle: {
|
||||||
|
backgroundColor: "white",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Keep content in separate components** - Don't inline large JSX in `showModal()` calls
|
||||||
|
2. **Use the hook in custom hooks** - Create specialized hooks like `useShowSuccessModal()` for reusable modal patterns
|
||||||
|
3. **Handle cleanup** - The modal automatically clears content when closed
|
||||||
|
4. **Avoid nesting** - Don't show modals from within modals
|
||||||
|
5. **Consider UX** - Only use for important, contextual information that requires user attention
|
||||||
|
|
||||||
|
## Using with PlatformDropdown
|
||||||
|
|
||||||
|
When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good - No title in option group (title is on PlatformDropdown)
|
||||||
|
const optionGroups: OptionGroup[] = [
|
||||||
|
{
|
||||||
|
options: items.map((item) => ({
|
||||||
|
type: "radio",
|
||||||
|
label: item.name,
|
||||||
|
value: item,
|
||||||
|
selected: item.id === selected?.id,
|
||||||
|
onPress: () => onChange(item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
title="Select Item" // Title here
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Bad - Causes nested menu on iOS
|
||||||
|
const optionGroups: OptionGroup[] = [
|
||||||
|
{
|
||||||
|
title: "Items", // This creates a nested Picker on iOS
|
||||||
|
options: items.map((item) => ({
|
||||||
|
type: "radio",
|
||||||
|
label: item.name,
|
||||||
|
value: item,
|
||||||
|
selected: item.id === selected?.id,
|
||||||
|
onPress: () => onChange(item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Modal doesn't appear
|
||||||
|
- Ensure `GlobalModalProvider` is above the component calling `useGlobalModal()`
|
||||||
|
- Check that `BottomSheetModalProvider` is present in the tree
|
||||||
|
- Verify `GlobalModal` component is rendered
|
||||||
|
|
||||||
|
### Content is cut off
|
||||||
|
- Use `enableDynamicSizing: true` for auto-sizing
|
||||||
|
- Or specify appropriate `snapPoints`
|
||||||
|
|
||||||
|
### Modal won't close
|
||||||
|
- Ensure `enablePanDownToClose` is `true`
|
||||||
|
- Check that backdrop is clickable
|
||||||
|
- Use `hideModal()` for programmatic closing
|
||||||
14
README.md
14
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
|
||||||
@@ -70,6 +81,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
|
|||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
||||||
|
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.png"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 🧪 Beta Testing
|
### 🧪 Beta Testing
|
||||||
@@ -104,6 +116,7 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
|||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
|
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
||||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||||
4. run `npm run prebuild`
|
4. run `npm run prebuild`
|
||||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||||
@@ -228,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,14 +6,16 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add the background downloader plugin only for non-TV builds
|
|
||||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only override googleServicesFile if env var is set
|
||||||
|
const androidConfig = {};
|
||||||
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
android: {
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
|
||||||
},
|
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
82
app.json
82
app.json
@@ -2,12 +2,13 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.39.0",
|
"version": "0.52.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
|
"newArchEnabled": true,
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"requireFullScreen": true,
|
"requireFullScreen": true,
|
||||||
@@ -16,28 +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,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"entitlements": {
|
||||||
"icon": {
|
"com.apple.developer.networking.wifi-info": true
|
||||||
"dark": "./assets/images/icon-ios-plain.png",
|
|
||||||
"light": "./assets/images/icon-ios-light.png",
|
|
||||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
|
||||||
},
|
},
|
||||||
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
|
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 71,
|
"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",
|
||||||
@@ -47,28 +49,33 @@
|
|||||||
"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",
|
|
||||||
"expo-router",
|
|
||||||
"expo-font",
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"@react-native-tvos/config-tv",
|
||||||
{
|
{
|
||||||
"enableNotificationControls": true,
|
"appleTVImages": {
|
||||||
"enableBackgroundAudio": true,
|
"icon": "./assets/images/icon-tvos.png",
|
||||||
"androidExtensions": {
|
"iconSmall": "./assets/images/icon-tvos-small.png",
|
||||||
"useExoplayerRtsp": false,
|
"iconSmall2x": "./assets/images/icon-tvos-small-2x.png",
|
||||||
"useExoplayerSmoothStreaming": false,
|
"topShelf": "./assets/images/icon-tvos-topshelf.png",
|
||||||
"useExoplayerHls": true,
|
"topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png",
|
||||||
"useExoplayerDash": false
|
"topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png",
|
||||||
|
"topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png"
|
||||||
|
},
|
||||||
|
"infoPlist": {
|
||||||
|
"UIAppSupportsHDR": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"expo-router",
|
||||||
|
"expo-font",
|
||||||
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -77,11 +84,12 @@
|
|||||||
"useFrameworks": "static"
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"compileSdkVersion": 35,
|
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||||
|
"compileSdkVersion": 36,
|
||||||
"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": {
|
||||||
@@ -99,12 +107,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",
|
||||||
[
|
[
|
||||||
@@ -115,10 +117,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
|
||||||
["./plugins/withAndroidManifest.js"],
|
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
|
||||||
["./plugins/withGradleProperties.js"],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -134,8 +132,21 @@
|
|||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/with-runtime-framework-headers.js",
|
"expo-web-browser",
|
||||||
"react-native-bottom-tabs"
|
["./plugins/with-runtime-framework-headers.js"],
|
||||||
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
|
["./plugins/withAndroidManifest.js"],
|
||||||
|
["./plugins/withTrustLocalCerts.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
|
||||||
@@ -154,7 +165,6 @@
|
|||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
},
|
}
|
||||||
"newArchEnabled": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { 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
|
||||||
@@ -28,7 +33,7 @@ export default function favorites() {
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='my-4'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||||
<Favorites />
|
<Favorites />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
212
app/(auth)/(tabs)/(favorites)/see-all.tsx
Normal file
212
app/(auth)/(tabs)/(favorites)/see-all.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useWindowDimensions, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type FavoriteTypes =
|
||||||
|
| "Series"
|
||||||
|
| "Movie"
|
||||||
|
| "Episode"
|
||||||
|
| "Video"
|
||||||
|
| "BoxSet"
|
||||||
|
| "Playlist";
|
||||||
|
|
||||||
|
const favoriteTypes: readonly FavoriteTypes[] = [
|
||||||
|
"Series",
|
||||||
|
"Movie",
|
||||||
|
"Episode",
|
||||||
|
"Video",
|
||||||
|
"BoxSet",
|
||||||
|
"Playlist",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isFavoriteType(value: unknown): value is FavoriteTypes {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(favoriteTypes as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FavoritesSeeAllScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const searchParams = useLocalSearchParams<{
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
}>();
|
||||||
|
const typeParam = searchParams.type;
|
||||||
|
const titleParam = searchParams.title;
|
||||||
|
|
||||||
|
const itemType = useMemo(() => {
|
||||||
|
if (!isFavoriteType(typeParam)) return null;
|
||||||
|
return typeParam as BaseItemKind;
|
||||||
|
}, [typeParam]);
|
||||||
|
|
||||||
|
const headerTitle = useMemo(() => {
|
||||||
|
if (typeof titleParam === "string" && titleParam.trim().length > 0)
|
||||||
|
return titleParam;
|
||||||
|
return "";
|
||||||
|
}, [titleParam]);
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
|
||||||
|
if (!api || !user?.Id || !itemType) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api as Api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
filters: ["IsFavorite"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
excludeLocationTypes: ["Virtual"],
|
||||||
|
enableTotalRecordCount: true,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
includeItemTypes: [itemType],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
[api, itemType, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ["favorites", "see-all", itemType],
|
||||||
|
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||||
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||||
|
return pages.reduce((acc, page) => acc + page.length, 0);
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id && !!itemType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||||
|
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (screenWidth < 350) return 2;
|
||||||
|
if (screenWidth < 600) return 3;
|
||||||
|
if (screenWidth < 900) return 5;
|
||||||
|
return 6;
|
||||||
|
}, [screenWidth]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf:
|
||||||
|
index % nrOfCols === 0
|
||||||
|
? "flex-end"
|
||||||
|
: (index + 1) % nrOfCols === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center",
|
||||||
|
width: "89%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
),
|
||||||
|
[nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [fetchNextPage, hasNextPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerTitle: headerTitle,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!itemType ? (
|
||||||
|
<View className='flex-1 items-center justify-center px-6'>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("favorites.noData", { defaultValue: "No items found." })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : isLoading ? (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlashList
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.8}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("home.no_items", { defaultValue: "No items" })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetching ? (
|
||||||
|
<View style={{ paddingVertical: 16 }}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
@@ -30,7 +32,6 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
|
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
@@ -42,57 +43,304 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/index'
|
name='downloads/index'
|
||||||
options={{
|
options={{
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
}}
|
headerLeft: () => (
|
||||||
/>
|
<Pressable
|
||||||
<Stack.Screen
|
onPress={() => _router.back()}
|
||||||
name='downloads/[seriesId]'
|
className='pl-0.5'
|
||||||
options={{
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
title: t("home.downloads.tvseries"),
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='sessions/index'
|
name='sessions/index'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.sessions.title"),
|
title: t("home.sessions.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings'
|
name='settings'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/marlin-search/page'
|
name='settings/playback-controls/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.playback_controls.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/jellyseerr/page'
|
name='settings/audio-subtitles/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.audio_subtitles.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/hide-libraries/page'
|
name='settings/appearance/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.appearance.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/music/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.music.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/appearance/hide-libraries/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.other.hide_libraries"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/plugins/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.plugins.plugins_title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/plugins/marlin-search/page'
|
||||||
|
options={{
|
||||||
|
title: "Marlin Search",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/plugins/jellyseerr/page'
|
||||||
|
options={{
|
||||||
|
title: "Jellyseerr",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/plugins/streamystats/page'
|
||||||
|
options={{
|
||||||
|
title: "Streamystats",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/plugins/kefinTweaks/page'
|
||||||
|
options={{
|
||||||
|
title: "KefinTweaks",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/intro/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.intro.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/logs/page'
|
name='settings/logs/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.logs.logs_title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='intro/page'
|
name='settings/network/page'
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
title: t("home.settings.network.title"),
|
||||||
title: "",
|
headerShown: !Platform.isTV,
|
||||||
presentation: "modal",
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
@@ -102,7 +350,12 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerLeft: () => (
|
||||||
|
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -116,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,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");
|
||||||
}}
|
}}
|
||||||
@@ -142,6 +395,6 @@ const SessionsButton = () => {
|
|||||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
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 { getDownloadedItems, deleteItems } = useDownload();
|
|
||||||
|
|
||||||
const series = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
getDownloadedItems()
|
|
||||||
?.filter((f) => f.item.SeriesId === seriesId)
|
|
||||||
?.sort(
|
|
||||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [getDownloadedItems]);
|
|
||||||
|
|
||||||
// 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 ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
|
||||||
return seasonGroups[Number(seasonIndex)] ?? [];
|
|
||||||
}, [seasonGroups, seasonIndex]);
|
|
||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
|
||||||
[groupBySeason],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (series.length > 0) {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: series[0].item.SeriesName,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
storage.delete(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),
|
|
||||||
style: "destructive",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}, [groupBySeason]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex-1'>
|
|
||||||
{series.length > 0 && (
|
|
||||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<ScrollView key={seasonIndex} className='px-4'>
|
|
||||||
{groupBySeason.map((episode, index) => (
|
|
||||||
<EpisodeCard key={index} item={episode} />
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,37 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import {
|
import { useNavigation } from "expo-router";
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useNavigation, useRouter } 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 { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, ScrollView, View } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
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";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||||
const {
|
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
|
||||||
removeProcess,
|
|
||||||
getDownloadedItems,
|
|
||||||
deleteFileByType,
|
|
||||||
deleteAllFiles,
|
|
||||||
} = useDownload();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const [showMigration, setShowMigration] = useState(false);
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
|
|
||||||
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const migration_20241124 = () => {
|
const migration_20241124 = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.downloads.new_app_version_requires_re_download"),
|
t("home.downloads.new_app_version_requires_re_download"),
|
||||||
@@ -62,7 +56,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadedFiles = getDownloadedItems();
|
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -106,9 +100,12 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
<Pressable
|
||||||
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
|
className='px-2'
|
||||||
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
@@ -119,7 +116,7 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [showMigration]);
|
}, [showMigration]);
|
||||||
|
|
||||||
const deleteMovies = () =>
|
const _deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -130,7 +127,7 @@ export default function page() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const _deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -141,212 +138,124 @@ export default function page() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const deleteOtherMedia = () =>
|
const _deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
otherMedia.map((item) =>
|
otherMedia
|
||||||
deleteFileByType(item.item.Type)
|
.filter((item) => item.item.Type)
|
||||||
.then(() =>
|
.map((item) =>
|
||||||
toast.success(
|
deleteFileByType(item.item.Type!)
|
||||||
t("home.downloads.toasts.deleted_media_successfully", {
|
.then(() =>
|
||||||
type: item.item.Type,
|
toast.success(
|
||||||
}),
|
t("home.downloads.toasts.deleted_media_successfully", {
|
||||||
),
|
type: item.item.Type,
|
||||||
)
|
}),
|
||||||
.catch((reason) => {
|
),
|
||||||
writeToLog("ERROR", reason);
|
)
|
||||||
toast.error(
|
.catch((reason) => {
|
||||||
t("home.downloads.toasts.failed_to_delete_media", {
|
writeToLog("ERROR", reason);
|
||||||
type: item.item.Type,
|
toast.error(
|
||||||
}),
|
t("home.downloads.toasts.failed_to_delete_media", {
|
||||||
);
|
type: item.item.Type,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllMedia = async () =>
|
|
||||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<OfflineModeProvider isOffline={true}>
|
||||||
<View style={{ flex: 1 }}>
|
<ScrollView
|
||||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
showsVerticalScrollIndicator={false}
|
||||||
<View className='py-4'>
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
>
|
||||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||||
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
|
<ActiveDownloads />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{movies.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<Text className='text-lg font-bold'>
|
<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>
|
</View>
|
||||||
|
)}
|
||||||
{movies.length > 0 && (
|
{groupedBySeries.length > 0 && (
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-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.movies")}
|
{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'>{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{movies?.map((item) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item.item}
|
|
||||||
isOffline
|
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.tvseries")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{groupedBySeries?.length}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{groupedBySeries?.map((items) => (
|
|
||||||
<View
|
|
||||||
className='mb-2 last:mb-0'
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
>
|
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.other_media")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{otherMedia?.length}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{otherMedia?.map((item) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item.item}
|
|
||||||
isOffline
|
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className='flex px-4'>
|
|
||||||
<Text className='opacity-50'>
|
|
||||||
{t("home.downloads.no_downloaded_items")}
|
|
||||||
</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>
|
||||||
)}
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
</View>
|
<View className='px-4 flex flex-row'>
|
||||||
</ScrollView>
|
{groupedBySeries?.map((items) => (
|
||||||
</View>
|
<View
|
||||||
<BottomSheetModal
|
className='mb-2 last:mb-0'
|
||||||
ref={bottomSheetModalRef}
|
key={items[0].item.SeriesId}
|
||||||
enableDynamicSizing
|
>
|
||||||
handleIndicatorStyle={{
|
<SeriesCard
|
||||||
backgroundColor: "white",
|
items={items.map((i) => i.item)}
|
||||||
}}
|
key={items[0].item.SeriesId}
|
||||||
backgroundStyle={{
|
/>
|
||||||
backgroundColor: "#171717",
|
</View>
|
||||||
}}
|
))}
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
</View>
|
||||||
<BottomSheetBackdrop
|
</ScrollView>
|
||||||
{...props}
|
</View>
|
||||||
disappearsOnIndex={-1}
|
)}
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
{otherMedia.length > 0 && (
|
||||||
)}
|
<View className='mb-4'>
|
||||||
>
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<BottomSheetView>
|
<Text className='text-lg font-bold'>
|
||||||
<View className='p-4 space-y-4 mb-4'>
|
{t("home.downloads.other_media")}
|
||||||
<Button color='purple' onPress={deleteMovies}>
|
</Text>
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</Button>
|
<Text className='text-xs font-bold'>
|
||||||
<Button color='purple' onPress={deleteShows}>
|
{otherMedia?.length}
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
</Text>
|
||||||
</Button>
|
</View>
|
||||||
{otherMedia.length > 0 && (
|
</View>
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
{t("home.downloads.delete_all_other_media_button")}
|
<View className='px-4 flex flex-row'>
|
||||||
</Button>
|
{otherMedia?.map((item) => (
|
||||||
)}
|
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||||
<Button color='red' onPress={deleteAllMedia}>
|
<MovieCard item={item.item} />
|
||||||
{t("home.downloads.delete_all_button")}
|
</TouchableItemRouter>
|
||||||
</Button>
|
))}
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</ScrollView>
|
||||||
</BottomSheetModal>
|
</View>
|
||||||
</>
|
)}
|
||||||
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
import { Home } from "../../../../components/home/Home";
|
||||||
|
|
||||||
export default function page() {
|
const Index = () => {
|
||||||
return <HomeIndex />;
|
return <Home />;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Index;
|
||||||
|
|||||||
@@ -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,11 +1,9 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import {
|
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
|
||||||
HardwareAccelerationType,
|
|
||||||
type SessionInfoDto,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
import {
|
||||||
GeneralCommandType,
|
GeneralCommandType,
|
||||||
PlaystateCommand,
|
PlaystateCommand,
|
||||||
|
SessionInfoDto,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
@@ -13,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -49,14 +47,13 @@ export default function page() {
|
|||||||
<FlashList
|
<FlashList
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||||
paddingHorizontal: 17,
|
paddingHorizontal: 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
}}
|
}}
|
||||||
data={sessions}
|
data={sessions}
|
||||||
renderItem={({ item }) => <SessionCard session={item} />}
|
renderItem={({ item }) => <SessionCard session={item} />}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
estimatedItemSize={200}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -8,34 +8,21 @@ 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 { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
|
||||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
|
||||||
import DownloadSettings from "@/components/settings/DownloadSettings";
|
|
||||||
import { GestureControls } from "@/components/settings/GestureControls";
|
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
|
||||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
|
||||||
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 { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
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);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
|
||||||
clearLogs();
|
|
||||||
successHapticFeedback();
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,7 +33,7 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-red-600'>
|
<Text className='text-red-600 px-2'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -56,66 +43,82 @@ export default function settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='p-4 flex flex-col gap-y-4'>
|
<View
|
||||||
<UserInfo />
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<UserInfo />
|
||||||
|
</View>
|
||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
<MediaProvider>
|
<View className='mb-4'>
|
||||||
<MediaToggles className='mb-4' />
|
<AppLanguageSelector />
|
||||||
<GestureControls className='mb-4' />
|
</View>
|
||||||
<AudioToggles className='mb-4' />
|
|
||||||
<SubtitleToggles className='mb-4' />
|
|
||||||
</MediaProvider>
|
|
||||||
|
|
||||||
<OtherSettings />
|
|
||||||
|
|
||||||
{!Platform.isTV && <DownloadSettings />}
|
|
||||||
|
|
||||||
<PluginSettings />
|
|
||||||
|
|
||||||
<AppLanguageSelector />
|
|
||||||
|
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
|
||||||
|
|
||||||
<ListGroup title={"Intro"}>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/intro/page");
|
|
||||||
}}
|
|
||||||
title={t("home.settings.intro.show_intro")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
textColor='red'
|
|
||||||
onPress={() => {
|
|
||||||
storage.set("hasShownIntro", false);
|
|
||||||
}}
|
|
||||||
title={t("home.settings.intro.reset_intro")}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<ListGroup title={t("home.settings.logs.logs_title")}>
|
<ListGroup title={t("home.settings.categories.title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/playback-controls/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.playback_controls.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.audio_subtitles.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/music/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.music.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/appearance/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.appearance.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.plugins.plugins_title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/intro/page")}
|
||||||
|
showArrow
|
||||||
|
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
|
||||||
title={t("home.settings.logs.logs_title")}
|
title={t("home.settings.logs.logs_title")}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
|
||||||
textColor='red'
|
|
||||||
onPress={onClearLogsClicked}
|
|
||||||
title={t("home.settings.logs.delete_all_logs")}
|
|
||||||
/>
|
|
||||||
</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 />;
|
||||||
|
}
|
||||||
|
|||||||
538
app/(auth)/(tabs)/(home)/settings.tv.tsx
Normal file
538
app/(auth)/(tabs)/(home)/settings.tv.tsx
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import type { TVOptionItem } from "@/components/tv";
|
||||||
|
import {
|
||||||
|
TVLogoutButton,
|
||||||
|
TVSectionHeader,
|
||||||
|
TVSettingsOptionButton,
|
||||||
|
TVSettingsRow,
|
||||||
|
TVSettingsStepper,
|
||||||
|
TVSettingsTextInput,
|
||||||
|
TVSettingsToggle,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
AudioTranscodeMode,
|
||||||
|
type MpvCacheMode,
|
||||||
|
TVTypographyScale,
|
||||||
|
useSettings,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function SettingsTV() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { logout } = useJellyfin();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
|
settings.openSubtitlesApiKey || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentAudioTranscode =
|
||||||
|
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
|
||||||
|
const currentSubtitleMode =
|
||||||
|
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
||||||
|
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
||||||
|
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||||
|
const currentTypographyScale =
|
||||||
|
settings.tvTypographyScale || TVTypographyScale.Default;
|
||||||
|
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
|
||||||
|
|
||||||
|
// 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Audio Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.audio.transcode_mode.title")}
|
||||||
|
value={audioTranscodeLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.audio.transcode_mode.title"),
|
||||||
|
options: audioTranscodeModeOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({ audioTranscodeMode: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isFirst
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtitles Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.subtitles.subtitle_mode")}
|
||||||
|
value={subtitleModeLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.subtitles.subtitle_mode"),
|
||||||
|
options: subtitleModeOptions,
|
||||||
|
onSelect: (value) => updateSettings({ subtitleMode: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
|
value={settings.rememberSubtitleSelections}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label={t("home.settings.subtitles.subtitle_size")}
|
||||||
|
value={settings.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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, Switch, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className='mt-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||||
|
{data?.map((view) => (
|
||||||
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
|
<Switch
|
||||||
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({
|
||||||
|
hiddenLibraries: value
|
||||||
|
? [...(settings.hiddenLibraries || []), view.Id!]
|
||||||
|
: settings.hiddenLibraries?.filter(
|
||||||
|
(id) => id !== view.Id,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
|
</Text>
|
||||||
|
</DisabledSetting>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/(auth)/(tabs)/(home)/settings/appearance/page.tsx
Normal file
25
app/(auth)/(tabs)/(home)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { AppearanceSettings } from "@/components/settings/AppearanceSettings";
|
||||||
|
|
||||||
|
export default function AppearancePage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<AppearanceSettings />
|
||||||
|
<View className='h-24' />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
Normal file
31
app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
|
import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings";
|
||||||
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
|
|
||||||
|
export default function AudioSubtitlesPage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<MediaProvider>
|
||||||
|
<AudioToggles className='mb-4' />
|
||||||
|
<SubtitleToggles className='mb-4' />
|
||||||
|
<MpvSubtitleSettings className='mb-4' />
|
||||||
|
</MediaProvider>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/(auth)/(tabs)/(home)/settings/intro/page.tsx
Normal file
45
app/(auth)/(tabs)/(home)/settings/intro/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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 { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
export default function IntroPage() {
|
||||||
|
const { showIntro } = useIntroSheet();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<ListGroup title={t("home.settings.intro.title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
showIntro();
|
||||||
|
}}
|
||||||
|
title={t("home.settings.intro.show_intro")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
textColor='red'
|
||||||
|
onPress={() => {
|
||||||
|
storage.set("hasShownIntro", false);
|
||||||
|
}}
|
||||||
|
title={t("home.settings.intro.reset_intro")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
<View className='h-24' />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
|
||||||
className='p-4'
|
|
||||||
>
|
|
||||||
<JellyseerrSettings />
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import * as FileSystem from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as Sharing from "expo-sharing";
|
import type * as SharingType from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
|
// Conditionally import expo-sharing only on non-TV platforms
|
||||||
|
const Sharing = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("expo-sharing") as typeof SharingType);
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
@@ -33,6 +39,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const _orderId = useId();
|
const _orderId = useId();
|
||||||
const _levelsId = useId();
|
const _levelsId = useId();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const filteredLogs = useMemo(
|
const filteredLogs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -47,27 +54,30 @@ export default function Page() {
|
|||||||
|
|
||||||
// Sharing it as txt while its formatted allows us to share it with many more applications
|
// Sharing it as txt while its formatted allows us to share it with many more applications
|
||||||
const share = useCallback(async () => {
|
const share = useCallback(async () => {
|
||||||
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
if (!Sharing) return;
|
||||||
|
|
||||||
|
const logsFile = new File(Paths.document, "logs.txt");
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
try {
|
||||||
.then(() => {
|
logsFile.write(JSON.stringify(filteredLogs));
|
||||||
setLoading(false);
|
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
||||||
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
} catch (e: any) {
|
||||||
})
|
writeErrorLog("Something went wrong attempting to export", e);
|
||||||
.catch((e) =>
|
} finally {
|
||||||
writeErrorLog("Something went wrong attempting to export", e),
|
setLoading(false);
|
||||||
)
|
}
|
||||||
.finally(() => setLoading(false));
|
}, [filteredLogs, Sharing]);
|
||||||
}, [filteredLogs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
loading ? (
|
loading ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity onPress={share}>
|
<TouchableOpacity onPress={share} className='px-2'>
|
||||||
<Text>{t("home.settings.logs.export_logs")}</Text>
|
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -75,7 +85,12 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View
|
||||||
|
className='flex-1'
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
@@ -157,6 +172,6 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Linking,
|
|
||||||
Switch,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
|
||||||
updateSettings({
|
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
|
||||||
});
|
|
||||||
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
|
||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
|
||||||
};
|
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
|
||||||
return (
|
|
||||||
pluginSettings?.searchEngine?.locked === true &&
|
|
||||||
pluginSettings?.marlinServerUrl?.locked === true
|
|
||||||
);
|
|
||||||
}, [pluginSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity onPress={() => onSave(value)}>
|
|
||||||
<Text className='text-blue-500'>
|
|
||||||
{t("home.settings.plugins.marlin_search.save_button")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [navigation, value]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting disabled={disabled} className='px-4'>
|
|
||||||
<ListGroup>
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
|
||||||
>
|
|
||||||
<ListItem
|
|
||||||
title={t(
|
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
|
||||||
)}
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.searchEngine === "Marlin"}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</DisabledSetting>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
|
||||||
showText={!pluginSettings?.searchEngine?.locked}
|
|
||||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
|
||||||
>
|
|
||||||
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
|
||||||
<Text className='mr-4'>
|
|
||||||
{t("home.settings.plugins.marlin_search.url")}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
editable={settings.searchEngine === "Marlin"}
|
|
||||||
className='text-white'
|
|
||||||
placeholder={t(
|
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
|
||||||
)}
|
|
||||||
value={value}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
onChangeText={(text) => setValue(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</DisabledSetting>
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
251
app/(auth)/(tabs)/(home)/settings/music/page.tsx
Normal file
251
app/(auth)/(tabs)/(home)/settings/music/page.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const CACHE_SIZE_OPTIONS = [
|
||||||
|
{ label: "100 MB", value: 100 },
|
||||||
|
{ label: "250 MB", value: 250 },
|
||||||
|
{ label: "500 MB", value: 500 },
|
||||||
|
{ label: "1 GB", value: 1024 },
|
||||||
|
{ label: "2 GB", value: 2048 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOOKAHEAD_COUNT_OPTIONS = [
|
||||||
|
{ label: "1 song", value: 1 },
|
||||||
|
{ label: "2 songs", value: 2 },
|
||||||
|
{ label: "3 songs", value: 3 },
|
||||||
|
{ label: "5 songs", value: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MusicSettingsPage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
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(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: CACHE_SIZE_OPTIONS.map((option) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: option.label,
|
||||||
|
value: String(option.value),
|
||||||
|
selected: option.value === settings?.audioMaxCacheSizeMB,
|
||||||
|
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings?.audioMaxCacheSizeMB, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentCacheSizeLabel =
|
||||||
|
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
|
||||||
|
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
|
||||||
|
|
||||||
|
const lookaheadCountOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: option.label,
|
||||||
|
value: String(option.value),
|
||||||
|
selected: option.value === settings?.audioLookaheadCount,
|
||||||
|
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings?.audioLookaheadCount, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentLookaheadLabel =
|
||||||
|
LOOKAHEAD_COUNT_OPTIONS.find(
|
||||||
|
(o) => o.value === settings?.audioLookaheadCount,
|
||||||
|
)?.label ?? `${settings?.audioLookaheadCount} songs`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.music.playback_title")}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{t("home.settings.music.playback_description")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.prefer_downloaded")}
|
||||||
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.preferLocalAudio}
|
||||||
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ preferLocalAudio: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className='mt-4'>
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.music.caching_title")}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{t("home.settings.music.caching_description")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.lookahead_enabled")}
|
||||||
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.audioLookaheadEnabled}
|
||||||
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ audioLookaheadEnabled: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.lookahead_count")}
|
||||||
|
disabled={
|
||||||
|
pluginSettings?.audioLookaheadCount?.locked ||
|
||||||
|
!settings.audioLookaheadEnabled
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={lookaheadCountOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentLookaheadLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.music.lookahead_count")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.max_cache_size")}
|
||||||
|
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={cacheSizeOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentCacheSizeLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.music.max_cache_size")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</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>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
app/(auth)/(tabs)/(home)/settings/network/page.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
Normal file
37
app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { GestureControls } from "@/components/settings/GestureControls";
|
||||||
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
|
import { MpvBufferSettings } from "@/components/settings/MpvBufferSettings";
|
||||||
|
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
||||||
|
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
||||||
|
|
||||||
|
export default function PlaybackControlsPage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<MediaProvider>
|
||||||
|
<MediaToggles className='mb-4' />
|
||||||
|
<GestureControls className='mb-4' />
|
||||||
|
<PlaybackControlsSettings />
|
||||||
|
<MpvBufferSettings />
|
||||||
|
</MediaProvider>
|
||||||
|
</View>
|
||||||
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ScrollView } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<JellyseerrSettings />
|
||||||
|
</DisabledSetting>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ScrollView } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<KefinTweaksSettings />
|
||||||
|
</DisabledSetting>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
142
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const onSave = (val: string) => {
|
||||||
|
updateSettings({
|
||||||
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
|
});
|
||||||
|
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return (
|
||||||
|
pluginSettings?.searchEngine?.locked === true &&
|
||||||
|
pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
);
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||||
|
<Text className='text-blue-500'>
|
||||||
|
{t("home.settings.plugins.marlin_search.save_button")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DisabledSetting disabled={disabled} className='px-4'>
|
||||||
|
<ListGroup>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={
|
||||||
|
pluginSettings?.searchEngine?.locked === true ||
|
||||||
|
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||||
|
}
|
||||||
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
|
)}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({
|
||||||
|
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
|
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
||||||
|
>
|
||||||
|
<Text className='mr-4'>
|
||||||
|
{t("home.settings.plugins.marlin_search.url")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
editable={settings.searchEngine === "Marlin"}
|
||||||
|
className='text-white'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={value}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={(text) => setValue(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</DisabledSetting>
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</DisabledSetting>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/(auth)/(tabs)/(home)/settings/plugins/page.tsx
Normal file
24
app/(auth)/(tabs)/(home)/settings/plugins/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
|
|
||||||
|
export default function PluginsPage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='px-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<PluginSettings />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
Normal file
262
app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
} = useSettings();
|
||||||
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
|
// Local state for all editable fields
|
||||||
|
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
||||||
|
const [useForSearch, setUseForSearch] = useState<boolean>(
|
||||||
|
settings?.searchEngine === "Streamystats",
|
||||||
|
);
|
||||||
|
const [movieRecs, setMovieRecs] = useState<boolean>(
|
||||||
|
settings?.streamyStatsMovieRecommendations ?? false,
|
||||||
|
);
|
||||||
|
const [seriesRecs, setSeriesRecs] = useState<boolean>(
|
||||||
|
settings?.streamyStatsSeriesRecommendations ?? false,
|
||||||
|
);
|
||||||
|
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
|
||||||
|
settings?.streamyStatsPromotedWatchlists ?? false,
|
||||||
|
);
|
||||||
|
const [hideWatchlistsTab, setHideWatchlistsTab] = useState<boolean>(
|
||||||
|
settings?.hideWatchlistsTab ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
|
const isStreamystatsEnabled = !!url;
|
||||||
|
|
||||||
|
const onSave = useCallback(() => {
|
||||||
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
|
updateSettings({
|
||||||
|
streamyStatsServerUrl: cleanUrl,
|
||||||
|
searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
|
||||||
|
streamyStatsMovieRecommendations: movieRecs,
|
||||||
|
streamyStatsSeriesRecommendations: seriesRecs,
|
||||||
|
streamyStatsPromotedWatchlists: promotedWatchlists,
|
||||||
|
hideWatchlistsTab: hideWatchlistsTab,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
|
||||||
|
}, [
|
||||||
|
url,
|
||||||
|
useForSearch,
|
||||||
|
movieRecs,
|
||||||
|
seriesRecs,
|
||||||
|
promotedWatchlists,
|
||||||
|
hideWatchlistsTab,
|
||||||
|
updateSettings,
|
||||||
|
queryClient,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Set up header save button
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={onSave}>
|
||||||
|
<Text className='text-blue-500 font-medium'>
|
||||||
|
{t("home.settings.plugins.streamystats.save")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, onSave, t]);
|
||||||
|
|
||||||
|
const handleClearStreamystats = useCallback(() => {
|
||||||
|
setUrl("");
|
||||||
|
setUseForSearch(false);
|
||||||
|
setMovieRecs(false);
|
||||||
|
setSeriesRecs(false);
|
||||||
|
setPromotedWatchlists(false);
|
||||||
|
setHideWatchlistsTab(false);
|
||||||
|
updateSettings({
|
||||||
|
streamyStatsServerUrl: "",
|
||||||
|
searchEngine: "Jellyfin",
|
||||||
|
streamyStatsMovieRecommendations: false,
|
||||||
|
streamyStatsSeriesRecommendations: false,
|
||||||
|
streamyStatsPromotedWatchlists: false,
|
||||||
|
hideWatchlistsTab: false,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
|
||||||
|
}, [updateSettings, queryClient, t]);
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||||
|
// Update local state with new values
|
||||||
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
|
setUrl(newUrl);
|
||||||
|
if (newUrl) {
|
||||||
|
setUseForSearch(true);
|
||||||
|
}
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='px-4'>
|
||||||
|
<ListGroup className='flex-1'>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.streamystats.url")}
|
||||||
|
disabledByAdmin={isUrlLocked}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
editable={!isUrlLocked}
|
||||||
|
className='text-white text-right flex-1'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={url}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={setUrl}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
|
||||||
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
|
{t(
|
||||||
|
"home.settings.plugins.streamystats.read_more_about_streamystats",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.plugins.streamystats.features_title")}
|
||||||
|
className='mt-4'
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
|
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={useForSearch}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
onValueChange={setUseForSearch}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
|
)}
|
||||||
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={movieRecs}
|
||||||
|
onValueChange={setMovieRecs}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
|
)}
|
||||||
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={seriesRecs}
|
||||||
|
onValueChange={setSeriesRecs}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
|
)}
|
||||||
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={promotedWatchlists}
|
||||||
|
onValueChange={setPromotedWatchlists}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
|
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={hideWatchlistsTab}
|
||||||
|
onValueChange={setHideWatchlistsTab}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||||
|
{!isUrlLocked && isStreamystatsEnabled && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleClearStreamystats}
|
||||||
|
className='mt-3 mb-4 py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-red-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.disable_streamystats")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemDtoQueryResult,
|
|
||||||
ItemSortBy,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getFilterApi,
|
|
||||||
getItemsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FlatList, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
genreFilterAtom,
|
|
||||||
SortByOption,
|
|
||||||
SortOrderOption,
|
|
||||||
sortByAtom,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
sortOrderOptions,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const [orientation, _setOrientation] = useState(
|
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
|
||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
|
||||||
queryKey: ["collection", collectionId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId: collectionId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
const data = response.data;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!collectionId,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({ title: collection?.Name || "" });
|
|
||||||
setSortOrder([SortOrderOption.Ascending]);
|
|
||||||
|
|
||||||
if (!collection) return;
|
|
||||||
|
|
||||||
// Convert the DisplayOrder to SortByOption
|
|
||||||
const displayOrder = collection.DisplayOrder as ItemSortBy;
|
|
||||||
const sortByOption = displayOrder
|
|
||||||
? SortByOption[displayOrder as keyof typeof SortByOption] ||
|
|
||||||
SortByOption.PremiereDate
|
|
||||||
: SortByOption.PremiereDate;
|
|
||||||
|
|
||||||
setSortBy([sortByOption]);
|
|
||||||
}, [navigation, collection]);
|
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
|
||||||
async ({
|
|
||||||
pageParam,
|
|
||||||
}: {
|
|
||||||
pageParam: number;
|
|
||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
|
||||||
if (!api || !collection) return null;
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: collectionId,
|
|
||||||
limit: 18,
|
|
||||||
startIndex: pageParam,
|
|
||||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
|
||||||
sortBy: [sortBy[0]],
|
|
||||||
sortOrder: [sortOrder[0]],
|
|
||||||
fields: [
|
|
||||||
"ItemCounts",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
"CanDelete",
|
|
||||||
"MediaSourceCount",
|
|
||||||
],
|
|
||||||
// true is needed for merged versions
|
|
||||||
recursive: true,
|
|
||||||
genres: selectedGenres,
|
|
||||||
tags: selectedTags,
|
|
||||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
|
||||||
includeItemTypes: ["Movie", "Series"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data || null;
|
|
||||||
},
|
|
||||||
[
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
collection,
|
|
||||||
selectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
selectedTags,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
|
||||||
queryKey: [
|
|
||||||
"collection-items",
|
|
||||||
collection,
|
|
||||||
selectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
selectedTags,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
],
|
|
||||||
queryFn: fetchItems,
|
|
||||||
getNextPageParam: (lastPage, pages) => {
|
|
||||||
if (
|
|
||||||
!lastPage?.Items ||
|
|
||||||
!lastPage?.TotalRecordCount ||
|
|
||||||
lastPage?.TotalRecordCount === 0
|
|
||||||
)
|
|
||||||
return undefined;
|
|
||||||
|
|
||||||
const totalItems = lastPage.TotalRecordCount;
|
|
||||||
const accumulatedItems = pages.reduce(
|
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
|
||||||
return lastPage?.Items?.length * pages.length;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
|
||||||
return (
|
|
||||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
marginBottom:
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
|
||||||
}}
|
|
||||||
item={item}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignSelf:
|
|
||||||
index % 3 === 0
|
|
||||||
? "flex-end"
|
|
||||||
: (index + 1) % 3 === 0
|
|
||||||
? "flex-start"
|
|
||||||
: "center",
|
|
||||||
width: "89%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ItemPoster item={item} />
|
|
||||||
{/* <MoviePoster item={item} /> */}
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
),
|
|
||||||
[orientation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
|
||||||
() => (
|
|
||||||
<View className=''>
|
|
||||||
<FlatList
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={{
|
|
||||||
display: "flex",
|
|
||||||
paddingHorizontal: 15,
|
|
||||||
paddingVertical: 16,
|
|
||||||
flexDirection: "row",
|
|
||||||
}}
|
|
||||||
extraData={[
|
|
||||||
selectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
selectedTags,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
]}
|
|
||||||
data={[
|
|
||||||
{
|
|
||||||
key: "reset",
|
|
||||||
component: <ResetFiltersButton />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "genre",
|
|
||||||
component: (
|
|
||||||
<FilterButton
|
|
||||||
className='mr-1'
|
|
||||||
id={collectionId}
|
|
||||||
queryKey='genreFilter'
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getFilterApi(
|
|
||||||
api,
|
|
||||||
).getQueryFiltersLegacy({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: collectionId,
|
|
||||||
});
|
|
||||||
return response.data.Genres || [];
|
|
||||||
}}
|
|
||||||
set={setSelectedGenres}
|
|
||||||
values={selectedGenres}
|
|
||||||
title={t("library.filters.genres")}
|
|
||||||
renderItemLabel={(item) => item.toString()}
|
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "year",
|
|
||||||
component: (
|
|
||||||
<FilterButton
|
|
||||||
className='mr-1'
|
|
||||||
id={collectionId}
|
|
||||||
queryKey='yearFilter'
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getFilterApi(
|
|
||||||
api,
|
|
||||||
).getQueryFiltersLegacy({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: collectionId,
|
|
||||||
});
|
|
||||||
return response.data.Years || [];
|
|
||||||
}}
|
|
||||||
set={setSelectedYears}
|
|
||||||
values={selectedYears}
|
|
||||||
title={t("library.filters.years")}
|
|
||||||
renderItemLabel={(item) => item.toString()}
|
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "tags",
|
|
||||||
component: (
|
|
||||||
<FilterButton
|
|
||||||
className='mr-1'
|
|
||||||
id={collectionId}
|
|
||||||
queryKey='tagsFilter'
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getFilterApi(
|
|
||||||
api,
|
|
||||||
).getQueryFiltersLegacy({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: collectionId,
|
|
||||||
});
|
|
||||||
return response.data.Tags || [];
|
|
||||||
}}
|
|
||||||
set={setSelectedTags}
|
|
||||||
values={selectedTags}
|
|
||||||
title={t("library.filters.tags")}
|
|
||||||
renderItemLabel={(item) => item.toString()}
|
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "sortBy",
|
|
||||||
component: (
|
|
||||||
<FilterButton
|
|
||||||
className='mr-1'
|
|
||||||
id={collectionId}
|
|
||||||
queryKey='sortBy'
|
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
|
||||||
set={setSortBy}
|
|
||||||
values={sortBy}
|
|
||||||
title={t("library.filters.sort_by")}
|
|
||||||
renderItemLabel={(item) =>
|
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
|
||||||
}
|
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "sortOrder",
|
|
||||||
component: (
|
|
||||||
<FilterButton
|
|
||||||
className='mr-1'
|
|
||||||
id={collectionId}
|
|
||||||
queryKey='sortOrder'
|
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
|
||||||
set={setSortOrder}
|
|
||||||
values={sortOrder}
|
|
||||||
title={t("library.filters.sort_order")}
|
|
||||||
renderItemLabel={(item) =>
|
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
|
||||||
}
|
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
renderItem={({ item }) => item.component}
|
|
||||||
keyExtractor={(item) => item.key}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
[
|
|
||||||
collectionId,
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
selectedGenres,
|
|
||||||
setSelectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
setSelectedYears,
|
|
||||||
selectedTags,
|
|
||||||
setSelectedTags,
|
|
||||||
sortBy,
|
|
||||||
setSortBy,
|
|
||||||
sortOrder,
|
|
||||||
setSortOrder,
|
|
||||||
isFetching,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!collection) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlashList
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
|
||||||
{t("search.no_results")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
extraData={[
|
|
||||||
selectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
selectedTags,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
]}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
data={flatData}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
estimatedItemSize={255}
|
|
||||||
numColumns={
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onEndReachedThreshold={0.5}
|
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
|
||||||
ItemSeparatorComponent={() => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default page;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import type React from "react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import Animated, {
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
|
||||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
|
||||||
const isOffline = offline === "true";
|
|
||||||
|
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: opacity.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const fadeOut = (callback: any) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fadeIn = (callback: any) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (item) {
|
|
||||||
fadeOut(() => {});
|
|
||||||
} else {
|
|
||||||
fadeIn(() => {});
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
if (isError)
|
|
||||||
return (
|
|
||||||
<View className='flex flex-col items-center justify-center h-screen w-screen'>
|
|
||||||
<Text>{t("item_card.could_not_load_item")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex flex-1 relative'>
|
|
||||||
<Animated.View
|
|
||||||
pointerEvents={"none"}
|
|
||||||
style={[animatedStyle]}
|
|
||||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
|
||||||
}}
|
|
||||||
className='bg-transparent rounded-lg mb-4 w-full'
|
|
||||||
/>
|
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
|
||||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
|
||||||
<View className='flex flex-row space-x-1 mb-8'>
|
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
|
||||||
</View>
|
|
||||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
|
||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
|
||||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
|
||||||
</Animated.View>
|
|
||||||
{item && <ItemContent item={item} isOffline={isOffline} />}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
|
||||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
|
||||||
id: string;
|
|
||||||
seasonIndex: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { data: item } = useQuery({
|
|
||||||
queryKey: ["series", seriesId],
|
|
||||||
queryFn: async () =>
|
|
||||||
await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: seriesId,
|
|
||||||
}),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
|
||||||
() =>
|
|
||||||
getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 1000,
|
|
||||||
}),
|
|
||||||
[item],
|
|
||||||
);
|
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
|
||||||
() =>
|
|
||||||
getLogoImageUrlById({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
}),
|
|
||||||
[item],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: allEpisodes, isLoading } = useQuery({
|
|
||||||
queryKey: ["AllEpisodes", item?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getTvShowsApi(api!).getEpisodes({
|
|
||||||
seriesId: item?.Id!,
|
|
||||||
userId: user?.Id!,
|
|
||||||
enableUserData: true,
|
|
||||||
// Note: Including trick play is necessary to enable trick play downloads
|
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
|
||||||
});
|
|
||||||
return res?.data.Items || [];
|
|
||||||
},
|
|
||||||
select: (data) =>
|
|
||||||
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
|
||||||
[...(data || [])].sort(
|
|
||||||
(a, b) =>
|
|
||||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
|
||||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
|
||||||
),
|
|
||||||
staleTime: 60,
|
|
||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () =>
|
|
||||||
!isLoading &&
|
|
||||||
item &&
|
|
||||||
allEpisodes &&
|
|
||||||
allEpisodes.length > 0 && (
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
|
||||||
<AddToFavorites item={item} />
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<DownloadItems
|
|
||||||
size='large'
|
|
||||||
title={t("item_card.download.download_series")}
|
|
||||||
items={allEpisodes || []}
|
|
||||||
MissingDownloadIconComponent={() => (
|
|
||||||
<Ionicons name='download' size={22} color='white' />
|
|
||||||
)}
|
|
||||||
DownloadedIconComponent={() => (
|
|
||||||
<Ionicons
|
|
||||||
name='checkmark-done-outline'
|
|
||||||
size={24}
|
|
||||||
color='#9333ea'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [allEpisodes, isLoading, item]);
|
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerHeight={400}
|
|
||||||
headerImage={
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: backdropUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
logo={
|
|
||||||
logoUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: logoUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: 130,
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
contentFit='contain'
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col pt-4'>
|
|
||||||
<SeriesHeader item={item} />
|
|
||||||
<View className='mb-4'>
|
|
||||||
<NextUp seriesId={seriesId} />
|
|
||||||
</View>
|
|
||||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default page;
|
|
||||||
@@ -0,0 +1,785 @@
|
|||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
|
ItemSortBy,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getFilterApi,
|
||||||
|
getItemsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { TVFilterButton } from "@/components/tv";
|
||||||
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
sortByAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 16;
|
||||||
|
const TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
const page: React.FC = () => {
|
||||||
|
const searchParams = useLocalSearchParams();
|
||||||
|
const { collectionId } = searchParams as { collectionId: string };
|
||||||
|
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const [orientation, _setOrientation] = useState(
|
||||||
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
|
const { data: collection, isLoading: isCollectionLoading } = useQuery({
|
||||||
|
queryKey: ["collection", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: collectionId,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
const data = response.data;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!collectionId,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TV Filter queries
|
||||||
|
const { data: tvGenreOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvYearOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Years || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvTagOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
|
setSortOrder([SortOrderOption.Ascending]);
|
||||||
|
|
||||||
|
if (!collection) return;
|
||||||
|
|
||||||
|
// Convert the DisplayOrder to SortByOption
|
||||||
|
const displayOrder = collection.DisplayOrder as ItemSortBy;
|
||||||
|
const sortByOption = displayOrder
|
||||||
|
? SortByOption[displayOrder as keyof typeof SortByOption] ||
|
||||||
|
SortByOption.PremiereDate
|
||||||
|
: SortByOption.PremiereDate;
|
||||||
|
|
||||||
|
setSortBy([sortByOption]);
|
||||||
|
}, [navigation, collection]);
|
||||||
|
|
||||||
|
// Calculate columns for TV grid
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
|
||||||
|
}, [screenWidth, orientation]);
|
||||||
|
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async ({
|
||||||
|
pageParam,
|
||||||
|
}: {
|
||||||
|
pageParam: number;
|
||||||
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
|
if (!api || !collection) return null;
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
limit: Platform.isTV ? 36 : 18,
|
||||||
|
startIndex: pageParam,
|
||||||
|
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||||
|
sortBy: [sortBy[0]],
|
||||||
|
sortOrder: [sortOrder[0]],
|
||||||
|
fields: [
|
||||||
|
"ItemCounts",
|
||||||
|
"PrimaryImageAspectRatio",
|
||||||
|
"CanDelete",
|
||||||
|
"MediaSourceCount",
|
||||||
|
],
|
||||||
|
// true is needed for merged versions
|
||||||
|
recursive: true,
|
||||||
|
genres: selectedGenres,
|
||||||
|
tags: selectedTags,
|
||||||
|
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||||
|
includeItemTypes: ["Movie", "Series"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data || null;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
collection,
|
||||||
|
collectionId,
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: [
|
||||||
|
"collection-items",
|
||||||
|
collectionId,
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
],
|
||||||
|
queryFn: fetchItems,
|
||||||
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
if (
|
||||||
|
!lastPage?.Items ||
|
||||||
|
!lastPage?.TotalRecordCount ||
|
||||||
|
lastPage?.TotalRecordCount === 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
|
const accumulatedItems = pages.reduce(
|
||||||
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accumulatedItems < totalItems) {
|
||||||
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return (
|
||||||
|
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginBottom:
|
||||||
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf:
|
||||||
|
index % 3 === 0
|
||||||
|
? "flex-end"
|
||||||
|
: (index + 1) % 3 === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center",
|
||||||
|
width: "89%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
),
|
||||||
|
[orientation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navTarget = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
() => (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
display: "flex",
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingVertical: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
}}
|
||||||
|
extraData={[
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
]}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
key: "reset",
|
||||||
|
component: <ResetFiltersButton />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "genre",
|
||||||
|
component: (
|
||||||
|
<FilterButton
|
||||||
|
className='mr-1'
|
||||||
|
id={collectionId}
|
||||||
|
queryKey='genreFilter'
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api,
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
}}
|
||||||
|
set={setSelectedGenres}
|
||||||
|
values={selectedGenres}
|
||||||
|
title={t("library.filters.genres")}
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "year",
|
||||||
|
component: (
|
||||||
|
<FilterButton
|
||||||
|
className='mr-1'
|
||||||
|
id={collectionId}
|
||||||
|
queryKey='yearFilter'
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api,
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Years || [];
|
||||||
|
}}
|
||||||
|
set={setSelectedYears}
|
||||||
|
values={selectedYears}
|
||||||
|
title={t("library.filters.years")}
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tags",
|
||||||
|
component: (
|
||||||
|
<FilterButton
|
||||||
|
className='mr-1'
|
||||||
|
id={collectionId}
|
||||||
|
queryKey='tagsFilter'
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api,
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
}}
|
||||||
|
set={setSelectedTags}
|
||||||
|
values={selectedTags}
|
||||||
|
title={t("library.filters.tags")}
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sortBy",
|
||||||
|
component: (
|
||||||
|
<FilterButton
|
||||||
|
className='mr-1'
|
||||||
|
id={collectionId}
|
||||||
|
queryKey='sortBy'
|
||||||
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
|
set={setSortBy}
|
||||||
|
values={sortBy}
|
||||||
|
title={t("library.filters.sort_by")}
|
||||||
|
renderItemLabel={(item) =>
|
||||||
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
|
}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sortOrder",
|
||||||
|
component: (
|
||||||
|
<FilterButton
|
||||||
|
className='mr-1'
|
||||||
|
id={collectionId}
|
||||||
|
queryKey='sortOrder'
|
||||||
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
|
set={setSortOrder}
|
||||||
|
values={sortOrder}
|
||||||
|
title={t("library.filters.sort_order")}
|
||||||
|
renderItemLabel={(item) =>
|
||||||
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
|
}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
renderItem={({ item }) => item.component}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
collectionId,
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
selectedGenres,
|
||||||
|
setSelectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
setSelectedYears,
|
||||||
|
selectedTags,
|
||||||
|
setSelectedTags,
|
||||||
|
sortBy,
|
||||||
|
setSortBy,
|
||||||
|
sortOrder,
|
||||||
|
setSortOrder,
|
||||||
|
isFetching,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Mobile return
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("search.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
extraData={[
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
]}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV return with filter bar
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Filter bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
marginTop: insets.top + 100,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingHorizontal: TV_SCALE_PADDING,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowGenreFilter}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowYearFilter}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowTagFilter}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={handleShowSortByFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={handleShowSortOrderFilter}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<FlatList
|
||||||
|
key={`${orientation}-${nrOfCols}`}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("search.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: TV_SCALE_PADDING,
|
||||||
|
paddingRight: TV_SCALE_PADDING,
|
||||||
|
paddingTop: 20,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
|
const ItemContentSkeletonTV = Platform.isTV
|
||||||
|
? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const Page: React.FC = () => {
|
||||||
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
|
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
||||||
|
// (especially important for plugins like Gelato)
|
||||||
|
const {
|
||||||
|
data: item,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
} = useItemQuery(id, isOffline, undefined, [
|
||||||
|
ItemFields.MediaSources,
|
||||||
|
ItemFields.MediaSourceCount,
|
||||||
|
ItemFields.MediaStreams,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Lazily preload item with full media sources in background
|
||||||
|
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
||||||
|
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: opacity.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fast fade out when item loads (no setTimeout delay)
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
opacity.value = withTiming(0, { duration: 150 });
|
||||||
|
} else {
|
||||||
|
opacity.value = withTiming(1, { duration: 150 });
|
||||||
|
}
|
||||||
|
}, [item, opacity]);
|
||||||
|
|
||||||
|
if (isError)
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col items-center justify-center h-screen w-screen'>
|
||||||
|
<Text>{t("item_card.could_not_load_item")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
|
<View className='flex flex-1 relative'>
|
||||||
|
{/* Always render ItemContent - it handles loading state internally on TV */}
|
||||||
|
<ItemContent
|
||||||
|
item={item}
|
||||||
|
itemWithSources={itemWithSources}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Skeleton overlay - fades out when content loads */}
|
||||||
|
{!item && (
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents={"none"}
|
||||||
|
style={[animatedStyle]}
|
||||||
|
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
|
||||||
|
>
|
||||||
|
{Platform.isTV && ItemContentSkeletonTV ? (
|
||||||
|
<ItemContentSkeletonTV />
|
||||||
|
) : (
|
||||||
|
<View style={{ paddingHorizontal: 16, width: "100%" }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 450,
|
||||||
|
}}
|
||||||
|
className='bg-transparent rounded-lg mb-4 w-full'
|
||||||
|
/>
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||||
|
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||||
|
<View className='flex flex-row space-x-1 mb-8'>
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
</View>
|
||||||
|
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</OfflineModeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -21,19 +21,18 @@ export default function page() {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
type: DiscoverSliderType;
|
type: DiscoverSliderType; //This gets converted to a string because it's a url param
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, companyId],
|
queryKey: ["jellyseerr", "company", type, companyId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return jellyseerrApi?.discover(
|
||||||
`${
|
`${
|
||||||
type === DiscoverSliderType.NETWORKS
|
Number(type) === DiscoverSliderType.NETWORKS
|
||||||
? Endpoints.DISCOVER_TV_NETWORK
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||||
}/${companyId}`,
|
}/${companyId}`,
|
||||||
@@ -86,6 +85,7 @@ export default function page() {
|
|||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
isLoading={isLoading}
|
||||||
logo={
|
logo={
|
||||||
<Image
|
<Image
|
||||||
id={companyId}
|
id={companyId}
|
||||||
@@ -8,43 +8,53 @@ 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";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, 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 { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
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 { 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 { 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 {
|
import {
|
||||||
type IssueType,
|
type IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
} 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 DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
// Mobile page component
|
||||||
|
const MobilePage: React.FC = () => {
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -60,11 +70,12 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
|
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
@@ -92,6 +103,46 @@ const Page: React.FC = () => {
|
|||||||
const [canRequest, hasAdvancedRequestPermission] =
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
useJellyseerrCanRequest(details);
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
|
const canManageRequests = useMemo(() => {
|
||||||
|
if (!jellyseerrUser) return false;
|
||||||
|
return hasPermission(
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
);
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const pendingRequest = useMemo(() => {
|
||||||
|
return details?.mediaInfo?.requests?.find(
|
||||||
|
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||||
|
);
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const handleApproveRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||||
|
console.error("Failed to approve request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||||
|
console.error("Failed to decline request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -115,6 +166,10 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
|
const handleIssueModalDismiss = useCallback(() => {
|
||||||
|
setIssueTypeDropdownOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setRequestBody = useCallback(
|
const setRequestBody = useCallback(
|
||||||
(body: MediaRequestBody) => {
|
(body: MediaRequestBody) => {
|
||||||
_setRequestBody(body);
|
_setRequestBody(body);
|
||||||
@@ -128,9 +183,11 @@ const Page: React.FC = () => {
|
|||||||
mediaId: Number(result.id!),
|
mediaId: Number(result.id!),
|
||||||
mediaType: mediaType!,
|
mediaType: mediaType!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
seasons: (details as TvDetails)?.seasons
|
...(mediaType === MediaType.TV && {
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.map?.((s) => s.seasonNumber),
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
|
?.map?.((s) => s.seasonNumber),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
@@ -156,11 +213,31 @@ const Page: React.FC = () => {
|
|||||||
[details],
|
[details],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const issueTypeOptionGroups = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: t("jellyseerr.types"),
|
||||||
|
options: Object.entries(IssueTypeName)
|
||||||
|
.reverse()
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: value,
|
||||||
|
value: key,
|
||||||
|
selected: key === String(issueType),
|
||||||
|
onPress: () => setIssueType(key as unknown as IssueType),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[issueType, t],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
<TouchableOpacity
|
||||||
|
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
|
||||||
|
>
|
||||||
<ItemActions item={details} />
|
<ItemActions item={details} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -309,6 +386,60 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{canManageRequests && pendingRequest && (
|
||||||
|
<View className='flex flex-col space-y-2 mt-4'>
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||||
|
<Text className='text-sm text-neutral-400'>
|
||||||
|
{t("jellyseerr.requested_by", {
|
||||||
|
user:
|
||||||
|
pendingRequest.requestedBy?.displayName ||
|
||||||
|
pendingRequest.requestedBy?.username ||
|
||||||
|
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||||
|
t("jellyseerr.unknown_user"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex flex-row space-x-2'>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleApproveRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleDeclineRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='close-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -355,6 +486,8 @@ const Page: React.FC = () => {
|
|||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
|
stackBehavior='push'
|
||||||
|
onDismiss={handleIssueModalDismiss}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -364,50 +497,25 @@ const Page: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col w-full'>
|
||||||
<DropdownMenu.Root>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
<DropdownMenu.Trigger>
|
{t("jellyseerr.issue_type")}
|
||||||
<View className='flex flex-col'>
|
</Text>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<PlatformDropdown
|
||||||
{t("jellyseerr.issue_type")}
|
groups={issueTypeOptionGroups}
|
||||||
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{issueType
|
||||||
|
? IssueTypeName[issueType]
|
||||||
|
: t("jellyseerr.select_an_issue")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{issueType
|
|
||||||
? IssueTypeName[issueType]
|
|
||||||
: t("jellyseerr.select_an_issue")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
}
|
||||||
<DropdownMenu.Content
|
title={t("jellyseerr.types")}
|
||||||
loop={false}
|
open={issueTypeDropdownOpen}
|
||||||
side='bottom'
|
onOpenChange={setIssueTypeDropdownOpen}
|
||||||
align='center'
|
/>
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>
|
|
||||||
{t("jellyseerr.types")}
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
{Object.entries(IssueTypeName)
|
|
||||||
.reverse()
|
|
||||||
.map(([key, value], _idx) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={value}
|
|
||||||
onSelect={() =>
|
|
||||||
setIssueType(key as unknown as IssueType)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{value}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
@@ -436,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;
|
||||||
@@ -87,14 +87,15 @@ export default function page() {
|
|||||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||||
<Text className='opacity-50'>
|
<Text className='opacity-50'>
|
||||||
{t("jellyseerr.born")}{" "}
|
{t("jellyseerr.born")}{" "}
|
||||||
{new Date(data?.details?.birthday!).toLocaleDateString(
|
{data?.details?.birthday &&
|
||||||
`${locale}-${region}`,
|
new Date(data.details.birthday).toLocaleDateString(
|
||||||
{
|
`${locale}-${region}`,
|
||||||
year: "numeric",
|
{
|
||||||
month: "long",
|
year: "numeric",
|
||||||
day: "numeric",
|
month: "long",
|
||||||
},
|
day: "numeric",
|
||||||
)}{" "}
|
},
|
||||||
|
)}{" "}
|
||||||
| {data?.details?.placeOfBirth}
|
| {data?.details?.placeOfBirth}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -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" }} />
|
||||||
@@ -33,7 +33,6 @@ export default function page() {
|
|||||||
<View className='flex flex-1'>
|
<View className='flex flex-1'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={channels?.Items}
|
data={channels?.Items}
|
||||||
estimatedItemSize={76}
|
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View className='flex flex-row items-center px-4 mb-2'>
|
<View className='flex flex-row items-center px-4 mb-2'>
|
||||||
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import {
|
||||||
|
downloadTrack,
|
||||||
|
isPermanentlyDownloaded,
|
||||||
|
} from "@/providers/AudioStorage";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
||||||
|
|
||||||
|
export default function AlbumDetailScreen() {
|
||||||
|
const { albumId } = useLocalSearchParams<{ albumId: string }>();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { playQueue } = useMusicPlayer();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: album, isLoading: loadingAlbum } = useQuery({
|
||||||
|
queryKey: ["music-album", albumId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserLibraryApi(api!).getItem({
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: albumId!,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!albumId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||||
|
queryKey: ["music-album-tracks", albumId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: albumId,
|
||||||
|
sortBy: ["IndexNumber"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!albumId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: album?.Name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerStyle: { backgroundColor: "transparent" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
});
|
||||||
|
}, [album?.Name, navigation]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => (album ? getPrimaryImageUrl({ api, item: album }) : null),
|
||||||
|
[api, album],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDuration = useMemo(() => {
|
||||||
|
if (!tracks) return "";
|
||||||
|
const totalTicks = tracks.reduce(
|
||||||
|
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return runtimeTicksToMinutes(totalTicks);
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handlePlayAll = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
playQueue(tracks, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
const handleShuffle = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||||
|
playQueue(shuffled, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
// Check if all tracks are already permanently downloaded
|
||||||
|
const allTracksDownloaded = useMemo(() => {
|
||||||
|
if (!tracks || tracks.length === 0) return false;
|
||||||
|
return tracks.every((track) => isPermanentlyDownloaded(track.Id));
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handleDownloadAlbum = useCallback(async () => {
|
||||||
|
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
|
||||||
|
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||||
|
if (result?.url && !result.isTranscoding) {
|
||||||
|
await downloadTrack(track.Id, result.url, {
|
||||||
|
permanent: true,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
setIsDownloading(false);
|
||||||
|
}, [tracks, api, user?.Id, isDownloading]);
|
||||||
|
|
||||||
|
const isLoading = loadingAlbum || loadingTracks;
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !album) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.album_not_found")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={tracks || []}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
className='items-center px-4 pb-6 bg-black'
|
||||||
|
style={{ paddingTop: insets.top + 60 }}
|
||||||
|
>
|
||||||
|
{/* Album artwork */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='disc' size={60} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Album info */}
|
||||||
|
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||||
|
{album.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-purple-400 text-base mt-1'>
|
||||||
|
{album.AlbumArtist || album.Artists?.join(", ")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{album.ProductionYear && `${album.ProductionYear} • `}
|
||||||
|
{tracks?.length} tracks • {totalDuration}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Play buttons */}
|
||||||
|
<View className='flex flex-row mt-4 items-center'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayAll}
|
||||||
|
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.play")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleShuffle}
|
||||||
|
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='shuffle' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.shuffle")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleDownloadAlbum}
|
||||||
|
disabled={allTracksDownloaded || isDownloading}
|
||||||
|
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<ActivityIndicator size={20} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
allTracksDownloaded
|
||||||
|
? "checkmark-circle"
|
||||||
|
: "download-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
track={item}
|
||||||
|
index={index + 1}
|
||||||
|
queue={tracks}
|
||||||
|
showArtwork={false}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
|
||||||
|
|
||||||
|
export default function ArtistDetailScreen() {
|
||||||
|
const { artistId } = useLocalSearchParams<{ artistId: string }>();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { playQueue } = useMusicPlayer();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: artist, isLoading: loadingArtist } = useQuery({
|
||||||
|
queryKey: ["music-artist", artistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserLibraryApi(api!).getItem({
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: artistId!,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: albums, isLoading: loadingAlbums } = useQuery({
|
||||||
|
queryKey: ["music-artist-albums", artistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
artistIds: [artistId!],
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
sortBy: ["ProductionYear", "SortName"],
|
||||||
|
sortOrder: ["Descending", "Ascending"],
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: topTracks, isLoading: loadingTracks } = useQuery({
|
||||||
|
queryKey: ["music-artist-top-tracks", artistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
artistIds: [artistId!],
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
sortBy: ["PlayCount"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 10,
|
||||||
|
recursive: true,
|
||||||
|
filters: ["IsPlayed"],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: artist?.Name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerStyle: { backgroundColor: "transparent" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
});
|
||||||
|
}, [artist?.Name, navigation]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
|
||||||
|
[api, artist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlayAllTracks = useCallback(() => {
|
||||||
|
if (topTracks && topTracks.length > 0) {
|
||||||
|
playQueue(topTracks, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, topTracks]);
|
||||||
|
|
||||||
|
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !artist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!artist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.artist_not_found")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
// Top tracks section
|
||||||
|
if (topTracks && topTracks.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: "top-tracks",
|
||||||
|
title: t("music.top_tracks"),
|
||||||
|
type: "tracks" as const,
|
||||||
|
data: topTracks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums section
|
||||||
|
if (albums && albums.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: "albums",
|
||||||
|
title: t("music.tabs.albums"),
|
||||||
|
type: "albums" as const,
|
||||||
|
data: albums,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={sections}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
className='items-center px-4 pb-6 bg-black'
|
||||||
|
style={{ paddingTop: insets.top + 50 }}
|
||||||
|
>
|
||||||
|
{/* Artist image */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: ARTWORK_SIZE / 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='person' size={60} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Artist info */}
|
||||||
|
<Text className='text-white text-2xl font-bold mt-4 text-center'>
|
||||||
|
{artist.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Play button */}
|
||||||
|
{topTracks && topTracks.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayAllTracks}
|
||||||
|
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mt-4'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.play_top_tracks")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item: section }) => (
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||||
|
{section.type === "albums" ? (
|
||||||
|
<HorizontalScroll
|
||||||
|
data={section.data}
|
||||||
|
height={178}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
section.data
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((track, index) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
key={track.Id}
|
||||||
|
track={track}
|
||||||
|
index={index + 1}
|
||||||
|
queue={section.data}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
|
||||||
|
import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const ARTWORK_SIZE = 120;
|
||||||
|
|
||||||
|
export default function PlaylistDetailScreen() {
|
||||||
|
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { playQueue } = useMusicPlayer();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const removeFromPlaylist = useRemoveFromPlaylist();
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveFromPlaylist = useCallback(() => {
|
||||||
|
if (selectedTrack?.Id && playlistId) {
|
||||||
|
removeFromPlaylist.mutate({
|
||||||
|
playlistId,
|
||||||
|
entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedTrack, playlistId, removeFromPlaylist]);
|
||||||
|
|
||||||
|
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
|
||||||
|
queryKey: ["music-playlist", playlistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserLibraryApi(api!).getItem({
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: playlistId!,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!playlistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||||
|
queryKey: ["music-playlist-tracks", playlistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: playlistId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!playlistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: playlist?.Name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerStyle: { backgroundColor: "transparent" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setPlaylistOptionsOpen(true)}
|
||||||
|
className='p-1.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [playlist?.Name, navigation]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
|
||||||
|
[api, playlist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDuration = useMemo(() => {
|
||||||
|
if (!tracks) return "";
|
||||||
|
const totalTicks = tracks.reduce(
|
||||||
|
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return runtimeTicksToMinutes(totalTicks);
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handlePlayAll = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
playQueue(tracks, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
const handleShuffle = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||||
|
playQueue(shuffled, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
// Check if all tracks are already downloaded
|
||||||
|
const allTracksDownloaded = useMemo(() => {
|
||||||
|
if (!tracks || tracks.length === 0) return false;
|
||||||
|
return tracks.every((track) => !!getLocalPath(track.Id));
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handleDownloadPlaylist = useCallback(async () => {
|
||||||
|
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!track.Id || getLocalPath(track.Id)) continue;
|
||||||
|
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||||
|
if (result?.url && !result.isTranscoding) {
|
||||||
|
await downloadTrack(track.Id, result.url, {
|
||||||
|
permanent: true,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
setIsDownloading(false);
|
||||||
|
}, [tracks, api, user?.Id, isDownloading]);
|
||||||
|
|
||||||
|
const isLoading = loadingPlaylist || loadingTracks;
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !playlist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playlist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("music.playlist_not_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={tracks || []}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
className='items-center px-4 pb-6 bg-black'
|
||||||
|
style={{ paddingTop: insets.top + 50 }}
|
||||||
|
>
|
||||||
|
{/* Playlist artwork */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='list' size={60} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Playlist info */}
|
||||||
|
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||||
|
{playlist.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{tracks?.length} tracks • {totalDuration}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Play buttons */}
|
||||||
|
<View className='flex flex-row mt-4 items-center'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayAll}
|
||||||
|
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.play")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleShuffle}
|
||||||
|
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='shuffle' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.shuffle")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleDownloadPlaylist}
|
||||||
|
disabled={allTracksDownloaded || isDownloading}
|
||||||
|
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<ActivityIndicator size={20} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
allTracksDownloaded
|
||||||
|
? "checkmark-circle"
|
||||||
|
: "download-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
track={item}
|
||||||
|
index={index + 1}
|
||||||
|
queue={tracks}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
playlistId={playlistId}
|
||||||
|
onRemoveFromPlaylist={handleRemoveFromPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
<PlaylistOptionsSheet
|
||||||
|
open={playlistOptionsOpen}
|
||||||
|
setOpen={setPlaylistOptionsOpen}
|
||||||
|
playlist={playlist}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
|
import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
import {
|
||||||
|
buildOfflineSeriesFromEpisodes,
|
||||||
|
getDownloadedEpisodesForSeries,
|
||||||
|
} from "@/utils/downloads/offline-series";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
const page: React.FC = () => {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const {
|
||||||
|
id: seriesId,
|
||||||
|
seasonIndex,
|
||||||
|
offline: offlineParam,
|
||||||
|
} = params as {
|
||||||
|
id: string;
|
||||||
|
seasonIndex: string;
|
||||||
|
offline?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOffline = offlineParam === "true";
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { getDownloadedItems, downloadedItems } = useDownload();
|
||||||
|
|
||||||
|
// For offline mode, construct series data from downloaded episodes
|
||||||
|
// Include downloadedItems.length so query refetches when items are deleted
|
||||||
|
const { data: item } = useQuery({
|
||||||
|
queryKey: ["series", seriesId, isOffline, downloadedItems.length],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (isOffline) {
|
||||||
|
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
|
||||||
|
}
|
||||||
|
return await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: seriesId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// For offline mode, use stored base64 image
|
||||||
|
const base64Image = useMemo(() => {
|
||||||
|
if (isOffline) {
|
||||||
|
return storage.getString(seriesId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [isOffline, seriesId]);
|
||||||
|
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
if (isOffline && base64Image) {
|
||||||
|
return `data:image/jpeg;base64,${base64Image}`;
|
||||||
|
}
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 1000,
|
||||||
|
});
|
||||||
|
}, [isOffline, base64Image, api, item]);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(() => {
|
||||||
|
if (isOffline) {
|
||||||
|
return null; // No logo in offline mode
|
||||||
|
}
|
||||||
|
return getLogoImageUrlById({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}, [isOffline, api, item]);
|
||||||
|
|
||||||
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
|
queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (isOffline) {
|
||||||
|
return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
|
||||||
|
}
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: seriesId,
|
||||||
|
userId: user.Id,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
|
});
|
||||||
|
return res?.data.Items || [];
|
||||||
|
},
|
||||||
|
select: (data) =>
|
||||||
|
[...(data || [])].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||||
|
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
|
),
|
||||||
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't show header buttons in offline mode
|
||||||
|
if (isOffline) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () =>
|
||||||
|
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<AddToFavorites item={item} />
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<DownloadItems
|
||||||
|
size='large'
|
||||||
|
title={t("item_card.download.download_series")}
|
||||||
|
items={allEpisodes || []}
|
||||||
|
MissingDownloadIconComponent={() => (
|
||||||
|
<Ionicons name='download' size={22} color='white' />
|
||||||
|
)}
|
||||||
|
DownloadedIconComponent={() => (
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-done-outline'
|
||||||
|
size={24}
|
||||||
|
color='#9333ea'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null,
|
||||||
|
});
|
||||||
|
}, [allEpisodes, isLoading, item, isOffline]);
|
||||||
|
|
||||||
|
// For offline mode, we can show the page even without backdropUrl
|
||||||
|
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||||
|
|
||||||
|
// TV version
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
|
<TVSeriesPage
|
||||||
|
item={item}
|
||||||
|
allEpisodes={allEpisodes}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</OfflineModeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
|
<ParallaxScrollView
|
||||||
|
headerHeight={400}
|
||||||
|
headerImage={
|
||||||
|
backdropUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: backdropUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logo={
|
||||||
|
logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col pt-4'>
|
||||||
|
<SeriesHeader item={item} />
|
||||||
|
{!isOffline && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<NextUp seriesId={seriesId} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
</OfflineModeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +1,208 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Reset dropdown state when component unmounts or navigates away
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoize callbacks to prevent recreating on every render
|
||||||
|
const handleDisplayRow = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "row",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleDisplayList = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "list",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleImageStylePoster = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "poster",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleImageStyleCover = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleToggleTitles = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: !settings.libraryOptions.showTitles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleToggleStats = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showStats: !settings.libraryOptions.showStats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
// Memoize groups to prevent recreating the array on every render
|
||||||
|
const dropdownGroups = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: t("library.options.display"),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("library.options.row"),
|
||||||
|
value: "row",
|
||||||
|
selected: settings.libraryOptions.display === "row",
|
||||||
|
onPress: handleDisplayRow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("library.options.list"),
|
||||||
|
value: "list",
|
||||||
|
selected: settings.libraryOptions.display === "list",
|
||||||
|
onPress: handleDisplayList,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("library.options.image_style"),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("library.options.poster"),
|
||||||
|
value: "poster",
|
||||||
|
selected: settings.libraryOptions.imageStyle === "poster",
|
||||||
|
onPress: handleImageStylePoster,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("library.options.cover"),
|
||||||
|
value: "cover",
|
||||||
|
selected: settings.libraryOptions.imageStyle === "cover",
|
||||||
|
onPress: handleImageStyleCover,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Options",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: "toggle" as const,
|
||||||
|
label: t("library.options.show_titles"),
|
||||||
|
value: settings.libraryOptions.showTitles,
|
||||||
|
onToggle: handleToggleTitles,
|
||||||
|
disabled: settings.libraryOptions.imageStyle === "poster",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "toggle" as const,
|
||||||
|
label: t("library.options.show_stats"),
|
||||||
|
value: settings.libraryOptions.showStats,
|
||||||
|
onToggle: handleToggleStats,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
t,
|
||||||
|
settings.libraryOptions,
|
||||||
|
handleDisplayRow,
|
||||||
|
handleDisplayList,
|
||||||
|
handleImageStylePoster,
|
||||||
|
handleImageStyleCover,
|
||||||
|
handleToggleTitles,
|
||||||
|
handleToggleStats,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
if (!settings?.libraryOptions) return null;
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack>
|
||||||
<Stack>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='index'
|
||||||
name='index'
|
options={{
|
||||||
options={{
|
headerShown: !Platform.isTV,
|
||||||
headerShown: !Platform.isTV,
|
headerTitle: t("tabs.library"),
|
||||||
headerTitle: t("tabs.library"),
|
headerBlurEffect: "none",
|
||||||
headerBlurEffect: "none",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerShadowVisible: false,
|
||||||
headerShadowVisible: false,
|
headerRight: () =>
|
||||||
headerRight: () =>
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
!Platform.isTV && (
|
||||||
!Platform.isTV && (
|
<PlatformDropdown
|
||||||
<TouchableOpacity
|
open={dropdownOpen}
|
||||||
onPress={() => setOptionsSheetOpen(true)}
|
onOpenChange={setDropdownOpen}
|
||||||
className='flex flex-row items-center justify-center w-9 h-9'
|
trigger={
|
||||||
>
|
<View className='pl-1.5'>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
),
|
}
|
||||||
}}
|
title={t("library.options.display")}
|
||||||
/>
|
groups={dropdownGroups}
|
||||||
<Stack.Screen
|
/>
|
||||||
name='[libraryId]'
|
),
|
||||||
options={{
|
}}
|
||||||
title: "",
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
|
||||||
))}
|
|
||||||
<Stack.Screen
|
|
||||||
name='collections/[collectionId]'
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<LibraryOptionsSheet
|
|
||||||
open={optionsSheetOpen}
|
|
||||||
setOpen={setOptionsSheetOpen}
|
|
||||||
settings={settings.libraryOptions}
|
|
||||||
updateSettings={(options) =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
...options,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={pluginSettings?.libraryOptions?.locked}
|
|
||||||
/>
|
/>
|
||||||
</>
|
<Stack.Screen
|
||||||
|
name='[libraryId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
<Stack.Screen
|
||||||
|
name='collections/[collectionId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +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 { 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 !== "music")
|
|
||||||
.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: 17,
|
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
|
||||||
paddingBottom: 150,
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
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' />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
estimatedItemSize={200}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
85
app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
Normal file
85
app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
createMaterialTopTabNavigator,
|
||||||
|
MaterialTopTabNavigationEventMap,
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
} from "@react-navigation/material-top-tabs";
|
||||||
|
import type {
|
||||||
|
ParamListBase,
|
||||||
|
TabNavigationState,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
|
const TAB_LABEL_FONT_SIZE = 13;
|
||||||
|
const TAB_ITEM_HORIZONTAL_PADDING = 12;
|
||||||
|
|
||||||
|
export const Tab = withLayoutContext<
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
typeof Navigator,
|
||||||
|
TabNavigationState<ParamListBase>,
|
||||||
|
MaterialTopTabNavigationEventMap
|
||||||
|
>(Navigator);
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: t("music.title"),
|
||||||
|
headerStyle: { backgroundColor: "black" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
initialRouteName='suggestions'
|
||||||
|
keyboardDismissMode='none'
|
||||||
|
screenOptions={{
|
||||||
|
tabBarBounces: true,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: TAB_LABEL_FONT_SIZE,
|
||||||
|
fontWeight: "600",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
},
|
||||||
|
tabBarItemStyle: {
|
||||||
|
width: "auto",
|
||||||
|
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
||||||
|
},
|
||||||
|
tabBarStyle: { backgroundColor: "black" },
|
||||||
|
animationEnabled: true,
|
||||||
|
lazy: true,
|
||||||
|
swipeEnabled: true,
|
||||||
|
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||||
|
tabBarScrollEnabled: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name='suggestions'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.suggestions") }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name='albums'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.albums") }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name='artists'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.artists") }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name='playlists'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.playlists") }}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
120
app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
Normal file
120
app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RefreshControl, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 40;
|
||||||
|
|
||||||
|
export default function AlbumsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["music-albums", libraryId, user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
sortBy: ["SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
startIndex: pageParam,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: response.data.Items || [],
|
||||||
|
totalCount: response.data.TotalRecordCount || 0,
|
||||||
|
startIndex: pageParam,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||||
|
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const albums = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albums.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.no_albums")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={albums}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item }) => <MusicAlbumRowCard album={item} />}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className='py-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
Normal file
157
app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RefreshControl, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MusicArtistCard } from "@/components/music/MusicArtistCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Web uses Limit=100
|
||||||
|
const ITEMS_PER_PAGE = 100;
|
||||||
|
|
||||||
|
export default function ArtistsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["music-artists", libraryId, user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const response = await getArtistsApi(api!).getArtists({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
sortBy: ["SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
startIndex: pageParam,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: response.data.Items || [],
|
||||||
|
totalCount: response.data.TotalRecordCount || 0,
|
||||||
|
startIndex: pageParam,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||||
|
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const artists = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Missing music library id.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show error if we have no cached data to display
|
||||||
|
// This allows offline access to previously cached artists
|
||||||
|
if (isError && artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Failed to load artists: {(error as Error)?.message || "Unknown error"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.no_artists")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={artists}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item }) => <MusicArtistCard artist={item} />}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className='py-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
Normal file
234
app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RefreshControl, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
||||||
|
import {
|
||||||
|
type PlaylistSortOption,
|
||||||
|
type PlaylistSortOrder,
|
||||||
|
PlaylistSortSheet,
|
||||||
|
} from "@/components/music/PlaylistSortSheet";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 40;
|
||||||
|
|
||||||
|
export default function PlaylistsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
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 handleSortChange = useCallback(
|
||||||
|
(newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
|
||||||
|
setSortBy(newSortBy);
|
||||||
|
setSortOrder(newSortOrder);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setCreateModalOpen(true)}
|
||||||
|
className='mr-4'
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='add' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: ["Playlist"],
|
||||||
|
sortBy: [sortBy],
|
||||||
|
sortOrder: [sortOrder],
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
startIndex: pageParam,
|
||||||
|
recursive: true,
|
||||||
|
mediaTypes: ["Audio"],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: response.data.Items || [],
|
||||||
|
totalCount: response.data.TotalRecordCount || 0,
|
||||||
|
startIndex: pageParam,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||||
|
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlists = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Missing music library id.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && playlists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show error if we have no cached data to display
|
||||||
|
// This allows offline access to previously cached playlists
|
||||||
|
if (isError && playlists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Failed to load playlists:{" "}
|
||||||
|
{(error as Error)?.message || "Unknown error"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setCreateModalOpen(true)}
|
||||||
|
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
|
||||||
|
>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("music.playlists.create_playlist")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createModalOpen}
|
||||||
|
setOpen={setCreateModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={playlists}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setSortSheetOpen(true)}
|
||||||
|
className='flex-row items-center mb-2 py-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='swap-vertical' size={18} color='#9334E9' />
|
||||||
|
<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!}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className='py-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createModalOpen}
|
||||||
|
setOpen={setCreateModalOpen}
|
||||||
|
/>
|
||||||
|
<PlaylistSortSheet
|
||||||
|
open={sortSheetOpen}
|
||||||
|
setOpen={setSortSheetOpen}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
Normal file
333
app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RefreshControl, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { writeDebugLog } from "@/utils/log";
|
||||||
|
|
||||||
|
export default function SuggestionsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
writeDebugLog("Music suggestions params", {
|
||||||
|
libraryId,
|
||||||
|
localParams,
|
||||||
|
routeParams: route?.params,
|
||||||
|
isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
|
||||||
|
// This returns the most recently added albums
|
||||||
|
const {
|
||||||
|
data: latestAlbums,
|
||||||
|
isLoading: loadingLatest,
|
||||||
|
isError: isLatestError,
|
||||||
|
error: latestError,
|
||||||
|
refetch: refetchLatest,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["music-latest", libraryId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
// Prefer the exact endpoint the Web client calls (HAR):
|
||||||
|
// /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
|
||||||
|
// IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
|
||||||
|
const res = await api!.get<BaseItemDto[]>(
|
||||||
|
`/Users/${user!.Id}/Items/Latest`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Limit: 20,
|
||||||
|
Fields: "PrimaryImageAspectRatio",
|
||||||
|
ParentId: libraryId,
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
||||||
|
EnableTotalRecordCount: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: ask for albums directly via /Items (more reliable across server variants)
|
||||||
|
const fallback = await getItemsApi(api!).getItems({
|
||||||
|
userId: user!.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
sortBy: ["DateCreated"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 20,
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
});
|
||||||
|
return fallback.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
|
||||||
|
const {
|
||||||
|
data: recentlyPlayed,
|
||||||
|
isLoading: loadingRecentlyPlayed,
|
||||||
|
isError: isRecentlyPlayedError,
|
||||||
|
error: recentlyPlayedError,
|
||||||
|
refetch: refetchRecentlyPlayed,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["music-recently-played", libraryId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
sortBy: ["DatePlayed"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 10,
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
filters: ["IsPlayed"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
|
||||||
|
const {
|
||||||
|
data: frequentlyPlayed,
|
||||||
|
isLoading: loadingFrequent,
|
||||||
|
isError: isFrequentError,
|
||||||
|
error: frequentError,
|
||||||
|
refetch: refetchFrequent,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["music-frequently-played", libraryId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
sortBy: ["PlayCount"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 10,
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
filters: ["IsPlayed"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refetchLatest();
|
||||||
|
refetchRecentlyPlayed();
|
||||||
|
refetchFrequent();
|
||||||
|
}, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
|
||||||
|
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const result: {
|
||||||
|
title: string;
|
||||||
|
data: BaseItemDto[];
|
||||||
|
type: "albums" | "tracks";
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// Latest albums section
|
||||||
|
if (latestAlbums && latestAlbums.length > 0) {
|
||||||
|
result.push({
|
||||||
|
title: t("music.recently_added"),
|
||||||
|
data: latestAlbums,
|
||||||
|
type: "albums",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recently played tracks
|
||||||
|
if (recentlyPlayed && recentlyPlayed.length > 0) {
|
||||||
|
result.push({
|
||||||
|
title: t("music.recently_played"),
|
||||||
|
data: recentlyPlayed,
|
||||||
|
type: "tracks",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequently played tracks
|
||||||
|
if (frequentlyPlayed && frequentlyPlayed.length > 0) {
|
||||||
|
result.push({
|
||||||
|
title: t("music.frequently_played"),
|
||||||
|
data: frequentlyPlayed,
|
||||||
|
type: "tracks",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
|
||||||
|
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Missing music library id.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show error if we have no cached data to display
|
||||||
|
// This allows offline access to previously cached suggestions
|
||||||
|
if (
|
||||||
|
(isLatestError || isRecentlyPlayedError || isFrequentError) &&
|
||||||
|
sections.length === 0
|
||||||
|
) {
|
||||||
|
const msg =
|
||||||
|
(latestError as Error | undefined)?.message ||
|
||||||
|
(recentlyPlayedError as Error | undefined)?.message ||
|
||||||
|
(frequentError as Error | undefined)?.message ||
|
||||||
|
"Unknown error";
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Failed to load music: {msg}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.no_suggestions")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={sections}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 16,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={({ item: section }) => (
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||||
|
{section.type === "albums" ? (
|
||||||
|
<HorizontalScroll
|
||||||
|
data={section.data}
|
||||||
|
height={178}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
section.data
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((track, index, _tracks) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
key={track.Id}
|
||||||
|
track={track}
|
||||||
|
index={index + 1}
|
||||||
|
queue={section.data}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.title}
|
||||||
|
/>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export default function SearchLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,13 +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 {
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
getItemNavigation,
|
||||||
import { Tag } from "@/components/GenreTags";
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
@@ -33,12 +35,25 @@ import {
|
|||||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
|
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 { 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";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -54,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);
|
||||||
|
|
||||||
@@ -67,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);
|
||||||
|
|
||||||
@@ -97,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 [];
|
||||||
@@ -107,27 +144,80 @@ 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[]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchEngine === "Streamystats") {
|
||||||
|
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamyStatsApi = createStreamystatsApi({
|
||||||
|
serverUrl: settings.streamyStatsServerUrl,
|
||||||
|
jellyfinToken: api.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeMap: Record<BaseItemKind, string> = {
|
||||||
|
Movie: "movies",
|
||||||
|
Series: "series",
|
||||||
|
Episode: "episodes",
|
||||||
|
Person: "actors",
|
||||||
|
BoxSet: "movies",
|
||||||
|
Audio: "audio",
|
||||||
|
} as Record<BaseItemKind, string>;
|
||||||
|
|
||||||
|
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
|
||||||
|
const response = await streamyStatsApi.searchIds(
|
||||||
|
query,
|
||||||
|
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
||||||
|
10,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allIds: string[] = [
|
||||||
|
...(response.data.movies || []),
|
||||||
|
...(response.data.series || []),
|
||||||
|
...(response.data.episodes || []),
|
||||||
|
...(response.data.actors || []),
|
||||||
|
...(response.data.audio || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsResponse = await getItemsApi(api).getItems(
|
||||||
|
{
|
||||||
|
ids: allIds,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marlin search
|
||||||
if (!settings?.marlinServerUrl) {
|
if (!settings?.marlinServerUrl) {
|
||||||
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;
|
||||||
|
|
||||||
@@ -135,18 +225,63 @@ 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) {
|
||||||
console.error("Error during search:", error);
|
// Silently handle aborted requests
|
||||||
return []; // Ensure an empty array is returned in case of an error
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings],
|
[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 = {
|
||||||
@@ -195,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,
|
||||||
});
|
});
|
||||||
@@ -205,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,
|
||||||
});
|
});
|
||||||
@@ -215,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,
|
||||||
});
|
});
|
||||||
@@ -225,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,
|
||||||
});
|
});
|
||||||
@@ -235,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,
|
||||||
});
|
});
|
||||||
@@ -245,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
|
||||||
@@ -260,91 +633,35 @@ 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={{
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<ScrollView
|
<View className='pl-4 pr-4 flex flex-row'>
|
||||||
horizontal
|
<SearchTabButtons
|
||||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
searchType={searchType}
|
||||||
>
|
setSearchType={setSearchType}
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
t={t}
|
||||||
<Tag
|
/>
|
||||||
text={t("search.library")}
|
|
||||||
textClass='p-1'
|
|
||||||
className={
|
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
|
||||||
<Tag
|
|
||||||
text={t("search.discover")}
|
|
||||||
textClass='p-1'
|
|
||||||
className={
|
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{searchType === "Discover" &&
|
{searchType === "Discover" &&
|
||||||
!loading &&
|
!loading &&
|
||||||
noResults &&
|
noResults &&
|
||||||
debouncedSearch.length > 0 && (
|
debouncedSearch.length > 0 && (
|
||||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
<DiscoverFilters
|
||||||
<FilterButton
|
searchFilterId={searchFilterId}
|
||||||
id={searchFilterId}
|
orderFilterId={orderFilterId}
|
||||||
queryKey='jellyseerr_search'
|
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||||
queryFn={async () =>
|
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||||
Number.isNaN(Number(v)),
|
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||||
)
|
t={t}
|
||||||
}
|
/>
|
||||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
|
||||||
values={[jellyseerrOrderBy]}
|
|
||||||
title={t("library.filters.sort_by")}
|
|
||||||
renderItemLabel={(item) =>
|
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
|
||||||
}
|
|
||||||
disableSearch={true}
|
|
||||||
/>
|
|
||||||
<FilterButton
|
|
||||||
id={orderFilterId}
|
|
||||||
queryKey='jellysearr_search'
|
|
||||||
queryFn={async () => ["asc", "desc"]}
|
|
||||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
|
||||||
values={[jellyseerrSortOrder]}
|
|
||||||
title={t("library.filters.sort_order")}
|
|
||||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
|
||||||
disableSearch={true}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className='mt-2'>
|
<View className='mt-2'>
|
||||||
@@ -435,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
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
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 />;
|
||||||
|
}
|
||||||
451
app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
Normal file
451
app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||||
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
|
import {
|
||||||
|
useDeleteWatchlist,
|
||||||
|
useRemoveFromWatchlist,
|
||||||
|
} from "@/hooks/useWatchlistMutations";
|
||||||
|
import {
|
||||||
|
useWatchlistDetailQuery,
|
||||||
|
useWatchlistItemsQuery,
|
||||||
|
} from "@/hooks/useWatchlists";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 20;
|
||||||
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
|
||||||
|
export default function WatchlistDetailScreen() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
|
const watchlistIdNum = watchlistId
|
||||||
|
? Number.parseInt(watchlistId, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
|
if (Platform.isTV) return 1;
|
||||||
|
if (screenWidth < 300) return 2;
|
||||||
|
if (screenWidth < 500) return 3;
|
||||||
|
if (screenWidth < 800) return 5;
|
||||||
|
if (screenWidth < 1000) return 6;
|
||||||
|
if (screenWidth < 1500) return 7;
|
||||||
|
return 6;
|
||||||
|
}, [screenWidth]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: watchlist,
|
||||||
|
isLoading: watchlistLoading,
|
||||||
|
refetch: refetchWatchlist,
|
||||||
|
} = useWatchlistDetailQuery(watchlistIdNum);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: items,
|
||||||
|
isLoading: itemsLoading,
|
||||||
|
refetch: refetchItems,
|
||||||
|
} = useWatchlistItemsQuery(watchlistIdNum);
|
||||||
|
|
||||||
|
const deleteWatchlist = useDeleteWatchlist();
|
||||||
|
const removeFromWatchlist = useRemoveFromWatchlist();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = useMemo(
|
||||||
|
() => watchlist?.userId === user?.Id,
|
||||||
|
[watchlist?.userId, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up header
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerTitle: watchlist?.name || "",
|
||||||
|
headerLeft: () => <HeaderBackButton />,
|
||||||
|
headerRight: isOwner
|
||||||
|
? () => (
|
||||||
|
<View className='flex-row gap-2'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
|
||||||
|
}
|
||||||
|
className='p-2'
|
||||||
|
>
|
||||||
|
<Ionicons name='pencil' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleDelete} className='p-2'>
|
||||||
|
<Ionicons name='trash-outline' size={20} color='#ef4444' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}, [navigation, watchlist?.name, isOwner, watchlistId]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await Promise.all([refetchWatchlist(), refetchItems()]);
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetchWatchlist, refetchItems]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
t("watchlists.delete_confirm_title"),
|
||||||
|
t("watchlists.delete_confirm_message", { name: watchlist?.name }),
|
||||||
|
[
|
||||||
|
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("watchlists.delete_button"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
if (watchlistIdNum) {
|
||||||
|
await deleteWatchlist.mutateAsync(watchlistIdNum);
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]);
|
||||||
|
|
||||||
|
const handleRemoveItem = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
if (!watchlistIdNum || !item.Id) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("watchlists.remove_item_title"),
|
||||||
|
t("watchlists.remove_item_message", { name: item.Name }),
|
||||||
|
[
|
||||||
|
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("watchlists.remove_button"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await removeFromWatchlist.mutateAsync({
|
||||||
|
watchlistId: watchlistIdNum,
|
||||||
|
itemId: item.Id!,
|
||||||
|
watchlistName: watchlist?.name,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
(item: BaseItemDto, index: number) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navigation = getItemNavigation(item, "(watchlists)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TVPosterCard
|
||||||
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
|
orientation='vertical'
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={() => showItemActions(item)}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
width={posterSizes.poster}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router, showItemActions, posterSizes.poster],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
onLongPress={isOwner ? () => handleRemoveItem(item) : undefined}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf:
|
||||||
|
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
? index % nrOfCols === 0
|
||||||
|
? "flex-end"
|
||||||
|
: (index + 1) % nrOfCols === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center"
|
||||||
|
: "center",
|
||||||
|
width: "89%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
),
|
||||||
|
[isOwner, handleRemoveItem, orientation, nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ListHeader = useMemo(
|
||||||
|
() =>
|
||||||
|
watchlist ? (
|
||||||
|
<View className='px-4 pt-4 pb-6 mb-4 border-b border-neutral-800'>
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text className='text-neutral-400 mb-2'>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View className='flex-row items-center gap-4'>
|
||||||
|
<View className='flex-row items-center gap-1'>
|
||||||
|
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{items?.length ?? 0}{" "}
|
||||||
|
{(items?.length ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex-row items-center gap-1'>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||||
|
}
|
||||||
|
size={14}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{watchlist.isPublic
|
||||||
|
? t("watchlists.public")
|
||||||
|
: t("watchlists.private")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isOwner && (
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{t("watchlists.by_owner")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null,
|
||||||
|
[watchlist, items?.length, isOwner, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyComponent = useMemo(
|
||||||
|
() => (
|
||||||
|
<View className='flex-1 items-center justify-center px-8 py-16'>
|
||||||
|
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||||
|
<Text className='text-neutral-400 text-center mt-4'>
|
||||||
|
{t("watchlists.empty_watchlist")}
|
||||||
|
</Text>
|
||||||
|
{isOwner && (
|
||||||
|
<Text className='text-neutral-500 text-center mt-2 text-sm'>
|
||||||
|
{t("watchlists.empty_watchlist_hint")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[isOwner, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
if (watchlistLoading || itemsLoading) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center'>
|
||||||
|
<ActivityIndicator size='large' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watchlist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center px-8'>
|
||||||
|
<Text className='text-lg text-neutral-400'>
|
||||||
|
{t("watchlists.not_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV layout with ScrollView + flexWrap
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 100,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingBottom: 24,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={20} color='#9ca3af' />
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{items?.length ?? 0}{" "}
|
||||||
|
{(items?.length ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{watchlist.isPublic
|
||||||
|
? t("watchlists.public")
|
||||||
|
: t("watchlists.private")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isOwner && (
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
|
||||||
|
{t("watchlists.by_owner")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid with flexWrap */}
|
||||||
|
{!items || items.length === 0 ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("watchlists.empty_watchlist")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => renderTVItem(item, index))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile layout with FlashList
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
key={orientation}
|
||||||
|
data={items ?? []}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
ListHeaderComponent={ListHeader}
|
||||||
|
ListEmptyComponent={EmptyComponent}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
renderItem={renderItem}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
app/(auth)/(tabs)/(watchlists)/_layout.tsx
Normal file
76
app/(auth)/(tabs)/(watchlists)/_layout.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||||
|
|
||||||
|
export default function WatchlistsLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const streamystatsEnabled = useStreamystatsEnabled();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name='index'
|
||||||
|
options={{
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerTitle: t("watchlists.title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerRight: streamystatsEnabled
|
||||||
|
? () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() =>
|
||||||
|
router.push("/(auth)/(tabs)/(watchlists)/create")
|
||||||
|
}
|
||||||
|
className='p-1.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='add' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='[watchlistId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='create'
|
||||||
|
options={{
|
||||||
|
title: t("watchlists.create_title"),
|
||||||
|
presentation: "modal",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
|
headerTintColor: "white",
|
||||||
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='edit/[watchlistId]'
|
||||||
|
options={{
|
||||||
|
title: t("watchlists.edit_title"),
|
||||||
|
presentation: "modal",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
|
headerTintColor: "white",
|
||||||
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
app/(auth)/(tabs)/(watchlists)/create.tsx
Normal file
221
app/(auth)/(tabs)/(watchlists)/create.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
|
import type {
|
||||||
|
StreamystatsWatchlistAllowedItemType,
|
||||||
|
StreamystatsWatchlistSortOrder,
|
||||||
|
} from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
const ITEM_TYPES: Array<{
|
||||||
|
value: StreamystatsWatchlistAllowedItemType;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: null, label: "All Types" },
|
||||||
|
{ value: "Movie", label: "Movies Only" },
|
||||||
|
{ value: "Series", label: "Series Only" },
|
||||||
|
{ value: "Episode", label: "Episodes Only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_OPTIONS: Array<{
|
||||||
|
value: StreamystatsWatchlistSortOrder;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "custom", label: "Custom Order" },
|
||||||
|
{ value: "name", label: "Name" },
|
||||||
|
{ value: "dateAdded", label: "Date Added" },
|
||||||
|
{ value: "releaseDate", label: "Release Date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CreateWatchlistScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const createWatchlist = useCreateWatchlist();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
|
const [allowedItemType, setAllowedItemType] =
|
||||||
|
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||||
|
const [defaultSortOrder, setDefaultSortOrder] =
|
||||||
|
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createWatchlist.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
});
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
// Error handled by mutation
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
createWatchlist,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className='flex-1'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className='flex-1'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.name_label")} *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder={t("watchlists.name_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.description_label")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder={t("watchlists.description_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
textAlignVertical='top'
|
||||||
|
style={{ minHeight: 80 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Public Toggle */}
|
||||||
|
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||||
|
<View className='flex-1 mr-4'>
|
||||||
|
<Text className='text-base font-medium text-white'>
|
||||||
|
{t("watchlists.is_public_label")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-sm text-neutral-400 mt-1'>
|
||||||
|
{t("watchlists.is_public_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={isPublic}
|
||||||
|
onValueChange={setIsPublic}
|
||||||
|
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||||
|
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Type */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.allowed_type_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{ITEM_TYPES.map((type) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={type.value ?? "all"}
|
||||||
|
onPress={() => setAllowedItemType(type.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
allowedItemType === type.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.sort_order_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{SORT_OPTIONS.map((sort) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={sort.value}
|
||||||
|
onPress={() => setDefaultSortOrder(sort.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
defaultSortOrder === sort.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sort.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Create Button */}
|
||||||
|
<View className='px-4 pt-4'>
|
||||||
|
<Button
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={!name.trim() || createWatchlist.isPending}
|
||||||
|
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
{createWatchlist.isPending ? (
|
||||||
|
<ActivityIndicator color='white' />
|
||||||
|
) : (
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold text-base'>
|
||||||
|
{t("watchlists.create_button")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
Normal file
274
app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
|
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
||||||
|
import type {
|
||||||
|
StreamystatsWatchlistAllowedItemType,
|
||||||
|
StreamystatsWatchlistSortOrder,
|
||||||
|
} from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
const ITEM_TYPES: Array<{
|
||||||
|
value: StreamystatsWatchlistAllowedItemType;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: null, label: "All Types" },
|
||||||
|
{ value: "Movie", label: "Movies Only" },
|
||||||
|
{ value: "Series", label: "Series Only" },
|
||||||
|
{ value: "Episode", label: "Episodes Only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_OPTIONS: Array<{
|
||||||
|
value: StreamystatsWatchlistSortOrder;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "custom", label: "Custom Order" },
|
||||||
|
{ value: "name", label: "Name" },
|
||||||
|
{ value: "dateAdded", label: "Date Added" },
|
||||||
|
{ value: "releaseDate", label: "Release Date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EditWatchlistScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||||
|
const watchlistIdNum = watchlistId
|
||||||
|
? Number.parseInt(watchlistId, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { data: watchlist, isLoading } =
|
||||||
|
useWatchlistDetailQuery(watchlistIdNum);
|
||||||
|
const updateWatchlist = useUpdateWatchlist();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
|
const [allowedItemType, setAllowedItemType] =
|
||||||
|
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||||
|
const [defaultSortOrder, setDefaultSortOrder] =
|
||||||
|
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||||
|
|
||||||
|
// Initialize form with watchlist data
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchlist) {
|
||||||
|
setName(watchlist.name);
|
||||||
|
setDescription(watchlist.description ?? "");
|
||||||
|
setIsPublic(watchlist.isPublic);
|
||||||
|
setAllowedItemType(
|
||||||
|
(watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
setDefaultSortOrder(
|
||||||
|
(watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ??
|
||||||
|
"custom",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [watchlist]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!name.trim() || !watchlistIdNum) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateWatchlist.mutateAsync({
|
||||||
|
watchlistId: watchlistIdNum,
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
// Error handled by mutation
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
watchlistIdNum,
|
||||||
|
updateWatchlist,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 items-center justify-center'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='large' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watchlist) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 items-center justify-center px-8'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<Text className='text-lg text-neutral-400'>
|
||||||
|
{t("watchlists.not_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className='flex-1'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className='flex-1'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.name_label")} *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder={t("watchlists.name_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.description_label")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder={t("watchlists.description_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
textAlignVertical='top'
|
||||||
|
style={{ minHeight: 80 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Public Toggle */}
|
||||||
|
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||||
|
<View className='flex-1 mr-4'>
|
||||||
|
<Text className='text-base font-medium text-white'>
|
||||||
|
{t("watchlists.is_public_label")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-sm text-neutral-400 mt-1'>
|
||||||
|
{t("watchlists.is_public_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={isPublic}
|
||||||
|
onValueChange={setIsPublic}
|
||||||
|
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||||
|
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Type */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.allowed_type_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{ITEM_TYPES.map((type) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={type.value ?? "all"}
|
||||||
|
onPress={() => setAllowedItemType(type.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
allowedItemType === type.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.sort_order_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{SORT_OPTIONS.map((sort) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={sort.value}
|
||||||
|
onPress={() => setDefaultSortOrder(sort.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
defaultSortOrder === sort.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sort.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<View className='px-4 pt-4'>
|
||||||
|
<Button
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!name.trim() || updateWatchlist.isPending}
|
||||||
|
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
{updateWatchlist.isPending ? (
|
||||||
|
<ActivityIndicator color='white' />
|
||||||
|
) : (
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Ionicons name='checkmark' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold text-base'>
|
||||||
|
{t("watchlists.save_button")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user