Compare commits

..

94 Commits

Author SHA1 Message Date
Fredrik Burmester
3438e78cab feat(tv): implement edge-to-edge horizontal sections for apple tv-like experience 2026-02-01 22:04:53 +01:00
Fredrik Burmester
67bca1f989 refactor(tv): have section pages fill width 2026-02-01 21:10:25 +01:00
Fredrik Burmester
c35e97f388 feat(tv): persist downloaded opensubtitles across app restarts 2026-02-01 19:19:32 +01:00
Fredrik Burmester
bc575c26c1 feat(mpv): add opaque subtitle background with adjustable opacity (iOS only) 2026-02-01 17:29:31 +01:00
Fredrik Burmester
ab526f2c6b chore(tv): suppress tvOS hover gesture warning 2026-02-01 16:39:04 +01:00
Fredrik Burmester
7d0b3be8c2 chore(tv): remove debug logs from back handler 2026-02-01 16:27:32 +01:00
Fredrik Burmester
a384b34402 chore: translations 2026-02-01 15:52:54 +01:00
Fredrik Burmester
07f535a6e4 fix: no log 2026-02-01 15:52:26 +01:00
Fredrik Burmester
2bcf52209e refactor(tv): remove native tv-player-controls module usage 2026-02-01 14:38:35 +01:00
Fredrik Burmester
fb7cee7718 fix(tv): improve skip/countdown focus and back button handling 2026-02-01 14:03:20 +01:00
Fredrik Burmester
2775075187 docs: add settings atom and translation key guidelines 2026-02-01 13:03:47 +01:00
Fredrik Burmester
4962f2161f refactor(tv): remove auto-scroll behaviors from search and series 2026-02-01 12:44:36 +01:00
Fredrik Burmester
25ec9c4348 fix(tv): remove automatic scroll triggers on series page 2026-02-01 12:39:05 +01:00
Fredrik Burmester
d17414bc93 fix(auth): distinguish session expiry from network errors 2026-02-01 12:27:22 +01:00
Fredrik Burmester
fea3e1449a fix(player): add null check for api in direct-player 2026-01-31 23:43:05 +01:00
Fredrik Burmester
ad1d9b5888 fix(tv): pause inactivity timer during video playback 2026-01-31 23:33:11 +01:00
Fredrik Burmester
3d406314a4 feat(tv): add configurable inactivity timeout with auto-logout 2026-01-31 22:29:39 +01:00
Fredrik Burmester
e6598f0944 perf(tv): optimize focus animations and disable native glass effect 2026-01-31 21:34:49 +01:00
Fredrik Burmester
f549e8eaed feat(tv): reorder series page buttons to center season selector 2026-01-31 21:03:54 +01:00
Fredrik Burmester
dab1c10a03 fix(tv): use single value for user-management entitlement 2026-01-31 19:55:22 +01:00
Fredrik Burmester
7e2962e539 fix(tv): remove deprecated presentUserPicker API 2026-01-31 18:59:30 +01:00
Fredrik Burmester
81cf672eb7 fix: convert native.js to native.ts and fix imports for EAS builds
- Convert utils/profiles/native.js to TypeScript
- Add barrel export index.ts for profiles
- Update all imports to use explicit file paths instead of barrel export
- Fix .gitignore to only ignore root-level profiles/ directory
2026-01-31 18:54:23 +01:00
Fredrik Burmester
591d89c19f feat(tv): local build eas creds 2026-01-31 17:52:35 +01:00
Fredrik Burmester
44b7434cdd refactor(tv): simplify user profile management with automatic sandboxing 2026-01-31 17:28:15 +01:00
Fredrik Burmester
717186e13e fix(tv): set node version 2026-01-31 15:00:38 +01:00
Fredrik Burmester
4afab8d94a fix(mpv): pause playback when tvOS app enters background 2026-01-31 13:42:52 +01:00
Fredrik Burmester
4601ae20b6 fix(tv): stop quick connect polling on login page exit 2026-01-31 12:58:44 +01:00
Fredrik Burmester
1ec887c29e feat(tv): new login design 2026-01-31 11:49:15 +01:00
Fredrik Burmester
85a74a9a6a refactor: login page 2026-01-31 10:52:21 +01:00
Fredrik Burmester
6e85c8d54a feat(tv): add user switching from settings 2026-01-31 09:53:54 +01:00
Fredrik Burmester
bf518b4834 fix(tv): poster images 2026-01-30 20:45:00 +01:00
Fredrik Burmester
d78ac2963f feat(tv): add language selector 2026-01-30 19:38:25 +01:00
Fredrik Burmester
2818c17e97 chore: sv translations 2026-01-30 19:16:53 +01:00
Fredrik Burmester
b87e7a159f fix(tv): home screen sections not loading 2026-01-30 19:09:31 +01:00
Fredrik Burmester
af2cac0e86 feat(player): add skip intro/credits support for tvOS 2026-01-30 18:52:22 +01:00
Fredrik Burmester
28e3060ace feat(player): add chapter navigation support with visual markers 2026-01-30 18:02:32 +01:00
Fredrik Burmester
3814237ac6 fix(glass-poster): prevent image overflow on tvOS 26 2026-01-30 09:16:01 +01:00
Fredrik Burmester
aed3a8f493 fix(tv): poster design and other stuff 2026-01-30 09:15:44 +01:00
Fredrik Burmester
0cd74519d4 fix(tv): correct episode image priority and scale animation in posters 2026-01-30 09:15:01 +01:00
Fredrik Burmester
8ecb7c205b feat(tv): add smart back button handler to prevent unwanted app exit 2026-01-29 21:53:45 +01:00
Fredrik Burmester
3827350ffd feat(tv): add focus management to next episode countdown card 2026-01-29 18:22:28 +01:00
Fredrik Burmester
53902aebab feat(tv): change playback options layout to horizontal row 2026-01-29 18:17:43 +01:00
Fredrik Burmester
bf3a37c61c feat(player): change technical info button icon to code-slash 2026-01-29 18:05:53 +01:00
Fredrik Burmester
2c0a9b6cd9 feat(tv): migrate login to white design with navigation modals 2026-01-29 12:12:20 +01:00
Fredrik Burmester
80136f1800 feat(tv): enable video playlists library with square thumbnail grid 2026-01-29 07:38:56 +01:00
Fredrik Burmester
01298c9b6d chore(i18n): add no_results translation key to common section 2026-01-29 07:32:13 +01:00
Fredrik Burmester
4bea01c963 fix(tv): prevent theme music from playing twice on shared transitions 2026-01-29 07:31:34 +01:00
Fredrik Burmester
94ac458f52 refactor(tv): use shared components and proper typography in actor page 2026-01-28 22:51:35 +01:00
Fredrik Burmester
409629bb4a feat(tv): add background theme music playback 2026-01-28 22:08:32 +01:00
Fredrik Burmester
2ff9625903 feat(tv): add long-press mark as watched action using alert dialog 2026-01-28 20:36:57 +01:00
Fredrik Burmester
8dcd4c40f9 chore: remove debug console.log statements from providers and layout 2026-01-28 20:21:56 +01:00
Fredrik Burmester
74114893e5 fix(tv): use router.replace for episode navigation to prevent page stacking 2026-01-28 19:57:54 +01:00
Fredrik Burmester
268a6d96de refactor(tv): swap poster and content layout in series page 2026-01-28 19:50:12 +01:00
Fredrik Burmester
2780b902e9 feat(tv): add favorite button to series detail page 2026-01-28 19:47:47 +01:00
Fredrik Burmester
6033958158 refactor(claude): restructure learned facts into individual files with compressed index 2026-01-28 19:45:51 +01:00
Fredrik Burmester
9763c26046 fix(player): handle remote streams and live tv containers correctly 2026-01-28 19:40:18 +01:00
Fredrik Burmester
05a2627c94 fix(auth): make logout device cleanup fire-and-forget 2026-01-28 19:31:20 +01:00
Fredrik Burmester
62a099e82f refactor(player): consolidate subtitle settings to use mpvSubtitleScale only 2026-01-26 21:01:25 +01:00
Fredrik Burmester
43ca6e9148 fix(player): disable subtitle scaling with window on iOS 2026-01-26 20:50:16 +01:00
Fredrik Burmester
1cbb46f0ca feat(player): add mpv cache and buffer configuration 2026-01-26 20:46:42 +01:00
Fredrik Burmester
21f2ceefc3 feat(tv): live tv 2026-01-26 20:30:50 +01:00
Fredrik Burmester
9d6a9decc9 style(tv): match live tv header styling to home tab 2026-01-26 19:59:30 +01:00
Fredrik Burmester
246e0af0f6 feat(tv): improve live tv guide scrolling and time range 2026-01-26 19:53:13 +01:00
Fredrik Burmester
a0dd752d8f feat(tv): add channels tab with direct channel playback and live tv controls 2026-01-26 19:42:10 +01:00
Fredrik Burmester
c5eb7b0c96 feat(tv): live tv initial commit 2026-01-26 19:41:03 +01:00
Fredrik Burmester
55c74ab383 feat(player): enable language-based track selection on mobile 2026-01-26 19:36:51 +01:00
Fredrik Burmester
7fe24369c0 feat(tv): add language-based audio and subtitle track selection 2026-01-26 19:32:06 +01:00
Fredrik Burmester
111397a306 refactor(tv): extract TVEpisodeList component to reduce code duplication 2026-01-26 19:17:29 +01:00
Fredrik Burmester
b79b343ce3 refactor(tv): replace poster multiplier scaling with linear offset-based scaling 2026-01-26 18:36:44 +01:00
Fredrik Burmester
c029228138 feat(tv): add now playing badge to current episode in season list 2026-01-26 18:15:52 +01:00
Fredrik Burmester
d51cf47eb4 feat(tv): add scalable poster sizes synchronized with typography settings 2026-01-26 18:04:22 +01:00
Fredrik Burmester
bbd7854287 fix(tv): resolve home sections not rendering when hero carousel is enabled 2026-01-26 18:03:18 +01:00
Fredrik Burmester
44caf4b1ff feat(i18n): add swedish translations for tv interface and fix hardcoded strings 2026-01-26 08:17:00 +01:00
Fredrik Burmester
92c70fadd1 refactor(tv): reorganize item detail page layout and improve episode list 2026-01-26 08:16:59 +01:00
Fredrik Burmester
f637367b82 style(tv): update episode section heading typography and spacing 2026-01-26 07:51:55 +01:00
Fredrik Burmester
715764cef8 feat(tv): add season episode list to episode detail page 2026-01-25 23:23:03 +01:00
Fredrik Burmester
36d6686258 feat(tv): increase typography scale multipliers for better visibility 2026-01-25 23:02:52 +01:00
Fredrik Burmester
dca7cc99f2 feat(tv): add setting to show series poster on episode detail pages 2026-01-25 23:01:08 +01:00
Fredrik Burmester
875a017e8c feat(tv): add scalable typography with user-configurable text size 2026-01-25 22:55:44 +01:00
Fredrik Burmester
0c6c20f563 feat(tv): add horizontal gradient fade to hero carousel backdrop 2026-01-25 22:53:24 +01:00
Fredrik Burmester
2c9906377d feat(tv): update skeleton layout to match swapped poster position 2026-01-25 21:31:37 +01:00
Fredrik Burmester
d5f7a18fe5 chore: docs 2026-01-25 20:19:45 +01:00
Fredrik Burmester
4606b9718e feat(tv): swap layout and add horizontal posters for episodes 2026-01-25 20:18:12 +01:00
Fredrik Burmester
c2d61654b0 feat(tv): add glass poster module and refactor grid layouts 2026-01-25 17:02:10 +01:00
Fredrik Burmester
2c6938c739 fix: design 2026-01-25 14:50:05 +01:00
Fredrik Burmester
1f454c0f12 feat(tv): add Apple TV+ style hero carousel to home page 2026-01-24 23:43:40 +01:00
Fredrik Burmester
c215fda973 feat(tv): add 60s background refresh for idle screens 2026-01-24 10:48:42 +01:00
Fredrik Burmester
a852e2e769 fix(tv): reposition streamystats sections after recently added 2026-01-24 10:35:15 +01:00
Fredrik Burmester
29873e08d7 feat(tv): add shared filter components and collections page support 2026-01-24 10:31:03 +01:00
Fredrik Burmester
5ce5cc2d99 refactor(home): reposition streamystats sections after recently added 2026-01-24 10:29:04 +01:00
Fredrik Burmester
ae5a71ff29 fix(auth): clear react query cache on logout and enable home screen refetch 2026-01-24 10:25:33 +01:00
Fredrik Burmester
0e3e8b8016 Revert "fix: scaling"
This reverts commit 566ff485fb.
2026-01-24 10:05:22 +01:00
Fredrik Burmester
d07a521f60 feat(tv): add trickplay bubble positioning aligned with progress bar 2026-01-23 22:39:44 +01:00
Fredrik Burmester
358e00d8b7 fix(player): resolve tvOS freeze on player exit by reordering mpv options
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-01-19 08:41:52 +01:00
233 changed files with 14653 additions and 6382 deletions

View 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]
```

View File

@@ -12,26 +12,59 @@ Analyze the current conversation to extract useful facts that should be remember
## Instructions
1. Read the existing facts file at `.claude/learned-facts.md`
1. Read the Learned Facts Index section in `CLAUDE.md` and scan existing files in `.claude/learned-facts/` to understand what's already recorded
2. Review this conversation for learnings worth preserving
3. For each new fact:
- Write it concisely (1-2 sentences max)
- Include context for why it matters
- Add today's date
- Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below
- Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md`
4. Skip facts that duplicate existing entries
5. Append new facts to `.claude/learned-facts.md`
5. If a new category is needed, add it to the index in `CLAUDE.md`
## Fact Format
## Fact File Template
Use this format for each fact:
```
- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_
Create each file at `.claude/learned-facts/[kebab-case-name].md`:
```markdown
# [Title]
**Date**: YYYY-MM-DD
**Category**: navigation | tv | native-modules | state-management | ui
**Key files**: `relevant/paths.ts`
## Detail
[Full description of the fact, including context for why it matters]
```
## Example Facts
## Index Entry Format
- **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_
- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_
- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_
Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`:
After updating the file, summarize what facts you added (or note if nothing new was learned this session).
```
- `kebab-case-name` | Brief one-line summary of the fact
```
Categories: Navigation, UI/Headers, State/Data, Native Modules, TV Platform
## Example
File `.claude/learned-facts/state-management-pattern.md`:
```markdown
# State Management Pattern
**Date**: 2025-01-09
**Category**: state-management
**Key files**: `utils/atoms/`
## Detail
Use Jotai atoms for global state, NOT React Context. Atoms are defined in `utils/atoms/`.
```
Index entry in `CLAUDE.md`:
```
State/Data:
- `state-management-pattern` | Use Jotai atoms for global state, not React Context
```
After updating, summarize what facts you added (or note if nothing new was learned this session).

View File

@@ -1,8 +1,11 @@
# Learned Facts
# Learned Facts (DEPRECATED)
This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions.
> **DEPRECATED**: This file has been replaced by individual fact files in `.claude/learned-facts/`.
> The compressed index is now inline in `CLAUDE.md` under "Learned Facts Index".
> New facts should be added as individual files using the `/reflect` command.
> This file is kept for reference only and is no longer auto-imported.
This file is auto-imported into CLAUDE.md and loaded at the start of each session.
This file previously contained facts about the codebase learned from past sessions.
## Facts
@@ -30,4 +33,16 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_

View 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`.

View 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.

View 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.

View 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.

View 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`.

View File

@@ -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).

View 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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View 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.

View File

@@ -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.

View 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.

View 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.

View File

@@ -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.

View File

@@ -0,0 +1,9 @@
# useNetworkAwareQueryClient Limitations
**Date**: 2026-01-10
**Category**: state-management
**Key files**: `hooks/useNetworkAwareQueryClient.ts`
## Detail
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.

2
.gitignore vendored
View File

@@ -61,6 +61,8 @@ expo-env.d.ts
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
streamyfin-4fec1-firebase-adminsdk.json
/profiles/
certs/
# Version and Backup Files
/version-backup-*

136
CLAUDE.md
View File

@@ -1,9 +1,39 @@
# CLAUDE.md
@.claude/learned-facts.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Learned Facts Index
IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in `.claude/learned-facts/` before relying on assumptions.
Navigation:
- `native-bottom-tabs-userouter-conflict` | useRouter() at provider level causes tab switches; use static router import
- `introsheet-rendering-location` | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks
- `intro-modal-trigger-location` | Trigger in Home.tsx, not tabs _layout.tsx
- `tab-folder-naming` | Use underscore prefix: (_home) not (home)
UI/Headers:
- `macos-header-buttons-fix` | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity
- `header-button-locations` | Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc.
- `stack-screen-header-configuration` | Sub-pages need explicit Stack.Screen with headerTransparent + back button
State/Data:
- `use-network-aware-query-client-limitations` | Object.create breaks private fields; only for invalidateQueries
- `mark-as-played-flow` | PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates
Native Modules:
- `mpv-tvos-player-exit-freeze` | mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global()
- `mpv-avfoundation-composite-osd-ordering` | MUST follow vo=avfoundation, before hwdec options
- `thread-safe-state-for-stop-flags` | Stop flags need synchronous setter (stateQueue.sync not async)
- `native-swiftui-view-sizing` | Need explicit frame + intrinsicContentSize override in ExpoView
TV Platform:
- `tv-modals-must-use-navigation-pattern` | Use atom+router.push(), never overlay/absolute modals
- `tv-grid-layout-pattern` | ScrollView+flexWrap, not FlatList numColumns
- `tv-horizontal-padding-standard` | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20
- `streamystats-components-location` | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx
- `platform-specific-file-suffix-does-not-work` | .tv.tsx doesn't work; use Platform.isTV conditional rendering
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
@@ -65,6 +95,7 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
**State Management**:
- Global state uses Jotai atoms in `utils/atoms/`
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
- **IMPORTANT**: When adding a setting to the settings atom, ensure it's toggleable in the settings view (either TV or mobile, depending on the feature scope)
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
- Server state uses React Query with `@tanstack/react-query`
@@ -128,6 +159,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- Handle both mobile and TV navigation patterns
- Use existing atoms, hooks, and utilities before creating new ones
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
- **Translations**: When adding a translation key to a Text component, ensure the key exists in both `translations/en.json` and `translations/sv.json`. Before adding new keys, check if an existing key already covers the use case.
## Platform Considerations
@@ -138,13 +170,13 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
- **TV 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 Modals**: Never use React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.**
### TV Component Rendering Pattern
**IMPORTANT**: The `.tv.tsx` file suffix only works for **pages** in the `app/` directory (resolved by Expo Router). It does NOT work for components - Metro bundler doesn't resolve platform-specific suffixes for component imports.
**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead.
**Pattern for TV-specific components**:
**Pattern for TV-specific pages and components**:
```typescript
// In page file (e.g., app/login.tsx)
import { Platform } from "react-native";
@@ -164,99 +196,11 @@ export default LoginPage;
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
- 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)
### TV Option Selectors and Focus Management
For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because:
- 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.
For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md).
### TV Focus Flickering Between Zones (Lists with Headers)

View File

@@ -76,6 +76,7 @@
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[
"expo-build-properties",
{

View File

@@ -1,15 +1,6 @@
import { useSettings } from "@/utils/atoms/settings";
import { Home } from "../../../../components/home/Home";
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
const Index = () => {
const { settings } = useSettings();
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
if (showLargeHomeCarousel) {
return <HomeWithCarousel />;
}
return <Home />;
};

View File

@@ -2,9 +2,11 @@ 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 { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
import type { TVOptionItem } from "@/components/tv";
import {
TVLogoutButton,
@@ -15,30 +17,161 @@ import {
TVSettingsTextInput,
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
import {
AudioTranscodeMode,
InactivityTimeout,
type MpvCacheMode,
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
import {
getPreviousServers,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { logout } = useJellyfin();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const { showOptions } = useTVOptionModal();
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
settings.openSubtitlesApiKey || "",
);
// PIN/Password modal state for user switching
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Track if any modal is open to disable background focus
const isAnyModalOpen = pinModalVisible || passwordModalVisible;
// Get current server and other accounts
const currentServer = useMemo(() => {
if (!api?.basePath) return null;
const servers = getPreviousServers();
return servers.find((s) => s.address === api.basePath) || null;
}, [api?.basePath]);
const otherAccounts = useMemo(() => {
if (!currentServer || !user?.Id) return [];
return currentServer.accounts.filter(
(account) => account.userId !== user.Id,
);
}, [currentServer, user?.Id]);
const hasOtherAccounts = otherAccounts.length > 0;
// Handle account selection from modal
const handleAccountSelect = async (account: SavedServerAccount) => {
if (!currentServer) return;
if (account.securityType === "none") {
// Direct login with saved credential
try {
await loginWithSavedCredential(currentServer.address, account.userId);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
);
}
} else if (account.securityType === "pin") {
// Show PIN modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPinModalVisible(true);
} else if (account.securityType === "password") {
// Show password modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPasswordModalVisible(true);
}
};
// Handle successful PIN entry
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (selectedServer && selectedAccount) {
try {
await loginWithSavedCredential(
selectedServer.address,
selectedAccount.userId,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
);
}
}
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle password submission
const handlePasswordSubmit = async (password: string) => {
if (selectedServer && selectedAccount) {
await loginWithPassword(
selectedServer.address,
selectedAccount.username,
password,
);
}
setPasswordModalVisible(false);
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle switch user button press
const handleSwitchUser = () => {
if (!currentServer || !user?.Id) return;
showUserSwitchModal(currentServer, user.Id, {
onAccountSelect: handleAccountSelect,
});
};
const currentAudioTranscode =
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
const currentSubtitleMode =
settings.subtitleMode || SubtitlePlaybackMode.Default;
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
const currentTypographyScale =
settings.tvTypographyScale || TVTypographyScale.Default;
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
const currentLanguage = settings.preferedLanguage;
// Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
@@ -130,6 +263,123 @@ export default function SettingsTV() {
[currentAlignY],
);
// Cache mode options
const cacheModeOptions: TVOptionItem<MpvCacheMode>[] = useMemo(
() => [
{
label: t("home.settings.buffer.cache_auto"),
value: "auto",
selected: currentCacheMode === "auto",
},
{
label: t("home.settings.buffer.cache_yes"),
value: "yes",
selected: currentCacheMode === "yes",
},
{
label: t("home.settings.buffer.cache_no"),
value: "no",
selected: currentCacheMode === "no",
},
],
[t, currentCacheMode],
);
// Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [
{
label: t("home.settings.appearance.display_size_small"),
value: TVTypographyScale.Small,
selected: currentTypographyScale === TVTypographyScale.Small,
},
{
label: t("home.settings.appearance.display_size_default"),
value: TVTypographyScale.Default,
selected: currentTypographyScale === TVTypographyScale.Default,
},
{
label: t("home.settings.appearance.display_size_large"),
value: TVTypographyScale.Large,
selected: currentTypographyScale === TVTypographyScale.Large,
},
{
label: t("home.settings.appearance.display_size_extra_large"),
value: TVTypographyScale.ExtraLarge,
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
},
],
[t, currentTypographyScale],
);
// Language options
const languageOptions: TVOptionItem<string | undefined>[] = useMemo(
() => [
{
label: t("home.settings.languages.system"),
value: undefined,
selected: !currentLanguage,
},
...APP_LANGUAGES.map((lang) => ({
label: lang.label,
value: lang.value,
selected: currentLanguage === lang.value,
})),
],
[t, currentLanguage],
);
// Inactivity timeout options (TV security feature)
const currentInactivityTimeout =
settings.inactivityTimeout ?? InactivityTimeout.Disabled;
const inactivityTimeoutOptions: TVOptionItem<InactivityTimeout>[] = useMemo(
() => [
{
label: t("home.settings.security.inactivity_timeout.disabled"),
value: InactivityTimeout.Disabled,
selected: currentInactivityTimeout === InactivityTimeout.Disabled,
},
{
label: t("home.settings.security.inactivity_timeout.1_minute"),
value: InactivityTimeout.OneMinute,
selected: currentInactivityTimeout === InactivityTimeout.OneMinute,
},
{
label: t("home.settings.security.inactivity_timeout.5_minutes"),
value: InactivityTimeout.FiveMinutes,
selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.15_minutes"),
value: InactivityTimeout.FifteenMinutes,
selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.30_minutes"),
value: InactivityTimeout.ThirtyMinutes,
selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.1_hour"),
value: InactivityTimeout.OneHour,
selected: currentInactivityTimeout === InactivityTimeout.OneHour,
},
{
label: t("home.settings.security.inactivity_timeout.4_hours"),
value: InactivityTimeout.FourHours,
selected: currentInactivityTimeout === InactivityTimeout.FourHours,
},
{
label: t("home.settings.security.inactivity_timeout.24_hours"),
value: InactivityTimeout.TwentyFourHours,
selected:
currentInactivityTimeout === InactivityTimeout.TwentyFourHours,
},
],
[t, currentInactivityTimeout],
);
// Get display labels for option buttons
const audioTranscodeLabel = useMemo(() => {
const option = audioTranscodeModeOptions.find((o) => o.selected);
@@ -151,6 +401,29 @@ export default function SettingsTV() {
return option?.label || "Bottom";
}, [alignYOptions]);
const typographyScaleLabel = useMemo(() => {
const option = typographyScaleOptions.find((o) => o.selected);
return option?.label || t("home.settings.appearance.display_size_default");
}, [typographyScaleOptions, t]);
const cacheModeLabel = useMemo(() => {
const option = cacheModeOptions.find((o) => o.selected);
return option?.label || t("home.settings.buffer.cache_auto");
}, [cacheModeOptions, t]);
const languageLabel = useMemo(() => {
if (!currentLanguage) return t("home.settings.languages.system");
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
return option?.label || t("home.settings.languages.system");
}, [currentLanguage, t]);
const inactivityTimeoutLabel = useMemo(() => {
const option = inactivityTimeoutOptions.find((o) => o.selected);
return (
option?.label || t("home.settings.security.inactivity_timeout.disabled")
);
}, [inactivityTimeoutOptions, t]);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
@@ -166,7 +439,7 @@ export default function SettingsTV() {
{/* Header */}
<Text
style={{
fontSize: 42,
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
@@ -175,6 +448,31 @@ export default function SettingsTV() {
{t("home.settings.settings_title")}
</Text>
{/* Account Section */}
<TVSectionHeader title={t("home.settings.switch_user.account")} />
<TVSettingsOptionButton
label={t("home.settings.switch_user.switch_user")}
value={user?.Name || "-"}
onPress={handleSwitchUser}
disabled={!hasOtherAccounts || isAnyModalOpen}
isFirst
/>
{/* Security Section */}
<TVSectionHeader title={t("home.settings.security.title")} />
<TVSettingsOptionButton
label={t("home.settings.security.inactivity_timeout.title")}
value={inactivityTimeoutLabel}
onPress={() =>
showOptions({
title: t("home.settings.security.inactivity_timeout.title"),
options: inactivityTimeoutOptions,
onSelect: (value) =>
updateSettings({ inactivityTimeout: value }),
})
}
/>
{/* Audio Section */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsOptionButton
@@ -188,7 +486,6 @@ export default function SettingsTV() {
updateSettings({ audioTranscodeMode: value }),
})
}
isFirst
/>
{/* Subtitles Section */}
@@ -215,26 +512,10 @@ export default function SettingsTV() {
/>
<TVSettingsStepper
label={t("home.settings.subtitles.subtitle_size")}
value={settings.subtitleSize / 100}
onDecrease={() => {
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
onIncrease={() => {
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
formatValue={(v) => `${v.toFixed(1)}x`}
/>
{/* MPV Subtitles Section */}
<TVSectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper
label='Subtitle Scale'
value={settings.mpvSubtitleScale ?? 1.0}
onDecrease={() => {
const newValue = Math.max(
0.5,
0.1,
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
);
updateSettings({
@@ -243,7 +524,7 @@ export default function SettingsTV() {
}}
onIncrease={() => {
const newValue = Math.min(
2.0,
3.0,
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
);
updateSettings({
@@ -309,7 +590,7 @@ export default function SettingsTV() {
<Text
style={{
color: "#9CA3AF",
fontSize: 14,
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
@@ -333,7 +614,7 @@ export default function SettingsTV() {
<Text
style={{
color: "#6B7280",
fontSize: 12,
fontSize: typography.callout - 4,
marginTop: 8,
marginLeft: 8,
}}
@@ -342,8 +623,103 @@ export default function SettingsTV() {
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
{/* Buffer Settings Section */}
<TVSectionHeader title={t("home.settings.buffer.title")} />
<TVSettingsOptionButton
label={t("home.settings.buffer.cache_mode")}
value={cacheModeLabel}
onPress={() =>
showOptions({
title: t("home.settings.buffer.cache_mode"),
options: cacheModeOptions,
onSelect: (value) => updateSettings({ mpvCacheEnabled: value }),
})
}
/>
<TVSettingsStepper
label={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10}
onDecrease={() => {
const newValue = Math.max(
5,
(settings.mpvCacheSeconds ?? 10) - 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
120,
(settings.mpvCacheSeconds ?? 10) + 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
formatValue={(v) => `${v}s`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_cache_size")}
value={settings.mpvDemuxerMaxBytes ?? 150}
onDecrease={() => {
const newValue = Math.max(
50,
(settings.mpvDemuxerMaxBytes ?? 150) - 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
500,
(settings.mpvDemuxerMaxBytes ?? 150) + 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_backward_cache")}
value={settings.mpvDemuxerMaxBackBytes ?? 50}
onDecrease={() => {
const newValue = Math.max(
25,
(settings.mpvDemuxerMaxBackBytes ?? 50) - 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
200,
(settings.mpvDemuxerMaxBackBytes ?? 50) + 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
{/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton
label={t("home.settings.appearance.display_size")}
value={typographyScaleLabel}
onPress={() =>
showOptions({
title: t("home.settings.appearance.display_size"),
options: typographyScaleOptions,
onSelect: (value) =>
updateSettings({ tvTypographyScale: value }),
})
}
/>
<TVSettingsOptionButton
label={t("home.settings.languages.app_language")}
value={languageLabel}
onPress={() =>
showOptions({
title: t("home.settings.languages.app_language"),
options: languageOptions,
onSelect: (value) =>
updateSettings({ preferedLanguage: value }),
})
}
/>
<TVSettingsToggle
label={t(
"home.settings.appearance.merge_next_up_continue_watching",
@@ -358,6 +734,23 @@ export default function SettingsTV() {
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
@@ -380,6 +773,37 @@ export default function SettingsTV() {
</View>
</ScrollView>
</View>
{/* PIN Entry Modal */}
<TVPINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
serverUrl={selectedServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<TVPasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
</View>
);
}

View File

@@ -3,6 +3,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { MpvBufferSettings } from "@/components/settings/MpvBufferSettings";
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
@@ -26,6 +27,7 @@ export default function PlaybackControlsPage() {
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<PlaybackControlsSettings />
<MpvBufferSettings />
</MediaProvider>
</View>
{!Platform.isTV && <ChromecastSettings />}

View File

@@ -15,14 +15,24 @@ import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native";
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVFilterButton } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
@@ -36,19 +46,29 @@ import {
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
const { width: screenWidth } = useWindowDimensions();
const [orientation, _setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP,
);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
@@ -56,7 +76,7 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const { data: collection } = useQuery({
const { data: collection, isLoading: isCollectionLoading } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
@@ -71,6 +91,46 @@ const page: React.FC = () => {
staleTime: 60 * 1000,
});
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvYearOptions } = useQuery({
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvTagOptions } = useQuery({
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]);
@@ -87,6 +147,18 @@ const page: React.FC = () => {
setSortBy([sortByOption]);
}, [navigation, collection]);
// Calculate columns for TV grid
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
}, [screenWidth, orientation]);
const fetchItems = useCallback(
async ({
pageParam,
@@ -98,7 +170,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 18,
limit: Platform.isTV ? 36 : 18,
startIndex: pageParam,
// Set one ordering at a time. As collections do not work with correctly with multiple.
sortBy: [sortBy[0]],
@@ -123,6 +195,7 @@ const page: React.FC = () => {
api,
user?.Id,
collection,
collectionId,
selectedGenres,
selectedYears,
selectedTags,
@@ -131,39 +204,40 @@ const page: React.FC = () => {
],
);
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: [
"collection-items",
collection,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: [
"collection-items",
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => {
return (
@@ -195,7 +269,6 @@ const page: React.FC = () => {
}}
>
<ItemPoster item={item} />
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
@@ -203,9 +276,34 @@ const page: React.FC = () => {
[orientation],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(home)");
router.push(navTarget as any);
};
const _insets = useSafeAreaInsets();
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
}}
>
<TVPosterCard
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
</View>
);
},
[router, showItemActions, posterSizes.poster],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
@@ -372,48 +470,315 @@ const page: React.FC = () => {
],
);
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedGenres.length === 0,
},
...(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
],
[tvGenreOptions, selectedGenres, t],
);
const tvYearFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedYears.length === 0,
},
...(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
],
[tvYearOptions, selectedYears, t],
);
const tvTagFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedTags.length === 0,
},
...(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
],
[tvTagOptions, selectedTags, t],
);
const tvSortByOptions = useMemo(
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
// TV Filter handlers using navigation-based modal
const handleShowGenreFilter = useCallback(() => {
showOptions({
title: t("library.filters.genres"),
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
title: t("library.filters.years"),
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
title: t("library.filters.tags"),
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
// TV filter bar state
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
if (isLoading || isCollectionLoading) {
return (
<View className='w-full h-full flex items-center justify-center'>
<Loader />
</View>
);
}
if (!collection) return null;
return (
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}
// TV return with filter bar
return (
<View style={{ flex: 1 }}>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/>
)}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
</View>
{/* Grid */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
removeClippedSubviews={false}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</View>
);
};

View File

@@ -7,7 +7,8 @@ import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router";
import { Slot, Stack, withLayoutContext } from "expo-router";
import { Platform } from "react-native";
const { Navigator } = createMaterialTopTabNavigator();
@@ -19,6 +20,17 @@ export const Tab = withLayoutContext<
>(Navigator);
const Layout = () => {
// On TV, skip the Material Top Tab Navigator and render children directly
// The TV version handles its own tab navigation internally
if (Platform.isTV) {
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<Slot />
</>
);
}
return (
<>
<Stack.Screen options={{ title: "Live TV" }} />

View File

@@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() {
if (Platform.isTV) {
return <TVLiveTVPage />;
}
return <MobileLiveTVPrograms />;
}
function MobileLiveTVPrograms() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();

View File

@@ -62,6 +62,7 @@ const page: React.FC = () => {
});
},
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id),
});
@@ -117,7 +118,8 @@ const page: React.FC = () => {
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: isOffline ? Infinity : 60,
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id),
});

View File

@@ -1,4 +1,3 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
@@ -12,23 +11,14 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
FlatList,
Platform,
Pressable,
ScrollView,
useWindowDimensions,
View,
@@ -44,13 +34,14 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVFilterButton, TVFocusablePoster } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
@@ -74,280 +65,13 @@ import {
yearFilterAtom,
} from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
// TV Filter Types and Components
type TVFilterModalType =
| "genre"
| "year"
| "tags"
| "sortBy"
| "sortOrder"
| "filterBy"
| null;
interface TVFilterOption<T> {
label: string;
value: T;
selected: boolean;
}
const TVFilterOptionCard: React.FC<{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 160,
height: 75,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View style={{ position: "absolute", top: 8, right: 8 }}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
const TVFilterButton: React.FC<{
label: string;
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
hasActiveFilter?: boolean;
}> = ({
label,
value,
onPress,
hasTVPreferredFocus,
disabled,
hasActiveFilter,
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.04);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View style={{ transform: [{ scale }] }}>
<View
style={{
backgroundColor: focused
? "#fff"
: hasActiveFilter
? "rgba(255, 255, 255, 0.25)"
: "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 8,
borderWidth: hasActiveFilter && !focused ? 1 : 0,
borderColor: "rgba(255, 255, 255, 0.4)",
}}
>
{label ? (
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
{label}
</Text>
) : null}
<Text
style={{
fontSize: 14,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
</View>
</Animated.View>
</Pressable>
);
};
const TVFilterSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVFilterOption<T>[];
onSelect: (value: T) => void;
onClose: () => void;
}) => {
// Track initial focus index - only set once when modal opens
const initialFocusIndexRef = useRef<number | null>(null);
// Calculate initial focus index only once when visible becomes true
if (visible && initialFocusIndexRef.current === null) {
const idx = options.findIndex((o) => o.selected);
initialFocusIndexRef.current = idx >= 0 ? idx : 0;
}
// Reset when modal closes
if (!visible) {
initialFocusIndexRef.current = null;
return null;
}
const initialFocusIndex = initialFocusIndexRef.current ?? 0;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View style={{ paddingVertical: 24 }}>
<Text
style={{
fontSize: 20,
fontWeight: "600",
color: "#fff",
paddingHorizontal: 48,
marginBottom: 16,
}}
>
{title}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVFilterOptionCard
key={String(option.value)}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const _TV_SCALE_PADDING = 20;
const TV_PLAYLIST_SQUARE_SIZE = 180;
const Page = () => {
const searchParams = useLocalSearchParams() as {
@@ -358,6 +82,8 @@ const Page = () => {
};
const { libraryId } = searchParams;
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions();
@@ -380,13 +106,8 @@ const Page = () => {
const { t } = useTranslation();
const router = useRouter();
// TV Filter modal state
const [openFilterModal, setOpenFilterModal] =
useState<TVFilterModalType>(null);
const isFilterModalOpen = openFilterModal !== null;
const isFiltersDisabled = isFilterModalOpen;
const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
@@ -511,12 +232,8 @@ const Page = () => {
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
// TV uses flexWrap, so nrOfCols is just for mobile
return 1;
}
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
@@ -569,6 +286,8 @@ const Page = () => {
itemType = "Video";
} else if (library.CollectionType === "musicvideos") {
itemType = "MusicVideo";
} else if (library.CollectionType === "playlists") {
itemType = "Playlist";
}
const response = await getItemsApi(api).getItems({
@@ -588,6 +307,9 @@ const Page = () => {
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: itemType ? [itemType] : undefined,
...(Platform.isTV && library.CollectionType === "playlists"
? { mediaTypes: ["Video"] }
: {}),
});
return response.data || null;
@@ -682,34 +404,84 @@ const Page = () => {
);
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
(item: BaseItemDto) => {
const handlePress = () => {
if (item.Type === "Playlist") {
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: { libraryId: item.Id! },
});
return;
}
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
};
// Special rendering for Playlist items (square thumbnails)
if (item.Type === "Playlist") {
const playlistImageUrl = getPrimaryImageUrl({
api,
item,
width: TV_PLAYLIST_SQUARE_SIZE * 2,
});
return (
<View
key={item.Id}
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
alignItems: "center",
}}
>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
<View
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
aspectRatio: 1,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
source={playlistImageUrl ? { uri: playlistImageUrl } : null}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, alignItems: "center" }}>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
</View>
</View>
);
}
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
<TVPosterCard
key={item.Id}
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
);
},
[router, isFilterModalOpen],
[router, showItemActions, api, typography],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
@@ -912,7 +684,7 @@ const Page = () => {
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVFilterOption<string>[] => [
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -928,7 +700,7 @@ const Page = () => {
);
const tvYearFilterOptions = useMemo(
(): TVFilterOption<string>[] => [
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -944,7 +716,7 @@ const Page = () => {
);
const tvTagFilterOptions = useMemo(
(): TVFilterOption<string>[] => [
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -960,7 +732,7 @@ const Page = () => {
);
const tvSortByOptions = useMemo(
(): TVFilterOption<SortByOption>[] =>
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
@@ -970,7 +742,7 @@ const Page = () => {
);
const tvSortOrderOptions = useMemo(
(): TVFilterOption<SortOrderOption>[] =>
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
@@ -980,7 +752,7 @@ const Page = () => {
);
const tvFilterByOptions = useMemo(
(): TVFilterOption<string>[] => [
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -995,56 +767,88 @@ const Page = () => {
[filterBy, generalFilters, t],
);
// TV Filter handlers
const handleGenreSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
[selectedGenres, setSelectedGenres],
);
// 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 handleYearSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
[selectedYears, setSelectedYears],
);
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 handleTagSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((t) => t !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
[selectedTags, setSelectedTags],
);
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 handleFilterBySelect = useCallback(
(value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
[setFilter, _setFilterBy],
);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
const handleShowFilterByFilter = useCallback(() => {
showOptions({
title: t("library.filters.filter_by"),
options: tvFilterByOptions,
onSelect: (value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
});
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
const insets = useSafeAreaInsets();
@@ -1097,185 +901,134 @@ const Page = () => {
);
}
// TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
// TV return with filter bar
return (
<View style={{ flex: 1 }}>
{/* Background content - disabled when modal is open */}
<View
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
focusable={!isFilterModalOpen}
isTVSelectable={!isFilterModalOpen}
pointerEvents={isFilterModalOpen ? "none" : "auto"}
accessibilityElementsHidden={isFilterModalOpen}
importantForAccessibility={
isFilterModalOpen ? "no-hide-descendants" : "auto"
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
onScroll={({ nativeEvent }) => {
// Load more when near bottom
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isNearBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - 500;
if (isNearBottom && hasNextPage && !isFetching) {
fetchNextPage();
}
}}
scrollEventThrottle={400}
>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
justifyContent: "center",
paddingBottom: 24,
gap: 12,
}}
>
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
disabled={isFiltersDisabled}
hasActiveFilter
/>
)}
{hasActiveFilters && (
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("genre")}
hasTVPreferredFocus={!hasActiveFilters}
disabled={isFiltersDisabled}
hasActiveFilter={selectedGenres.length > 0}
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
hasActiveFilter
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("year")}
disabled={isFiltersDisabled}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("tags")}
disabled={isFiltersDisabled}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={() => setOpenFilterModal("sortBy")}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={() => setOpenFilterModal("sortOrder")}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("filterBy")}
disabled={isFiltersDisabled}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols, isFilterModalOpen]}
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,
}}
/>
)}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={handleShowFilterByFilter}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* TV Filter Overlays */}
<TVFilterSelector
visible={openFilterModal === "genre"}
title={t("library.filters.genres")}
options={tvGenreFilterOptions}
onSelect={handleGenreSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "year"}
title={t("library.filters.years")}
options={tvYearFilterOptions}
onSelect={handleYearSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "tags"}
title={t("library.filters.tags")}
options={tvTagFilterOptions}
onSelect={handleTagSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "sortBy"}
title={t("library.filters.sort_by")}
options={tvSortByOptions}
onSelect={(value) => setSortBy([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "sortOrder"}
title={t("library.filters.sort_order")}
options={tvSortOrderOptions}
onSelect={(value) => setSortOrder([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "filterBy"}
title={t("library.filters.filter_by")}
options={tvFilterByOptions}
onSelect={handleFilterBySelect}
onClose={() => setOpenFilterModal(null)}
/>
</View>
{/* Grid with flexWrap */}
{flatData.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Text style={{ fontSize: typography.body, color: "#737373" }}>
{t("library.no_results")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{flatData.map((item) => renderTVItem(item))}
</View>
)}
{/* Loading indicator */}
{isFetching && (
<View style={{ paddingVertical: 20 }}>
<Loader />
</View>
)}
</ScrollView>
);
};

View File

@@ -42,6 +42,7 @@ import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -69,6 +70,7 @@ export default function search() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(search)";
@@ -607,6 +609,7 @@ export default function search() {
loading={loading}
noResults={noResults}
onItemPress={handleItemPress}
onItemLongPress={showItemActions}
searchType={searchType}
setSearchType={setSearchType}
showDiscover={!!jellyseerrApi}

View File

@@ -10,6 +10,7 @@ import {
Alert,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
useWindowDimensions,
View,
@@ -23,13 +24,12 @@ import {
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
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,
@@ -41,23 +41,15 @@ import {
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
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 }>();
@@ -70,14 +62,8 @@ export default function WatchlistDetailScreen() {
: undefined;
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
// 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;
@@ -185,34 +171,25 @@ export default function WatchlistDetailScreen() {
);
const renderTVItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
(item: BaseItemDto, index: number) => {
const handlePress = () => {
const navigation = getItemNavigation(item, "(watchlists)");
router.push(navigation as any);
};
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster
onPress={handlePress}
hasTVPreferredFocus={index === 0}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
<TVPosterCard
key={item.Id}
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={index === 0}
width={posterSizes.poster}
/>
);
},
[router],
[router, showItemActions, posterSizes.poster],
);
const renderItem = useCallback(
@@ -328,6 +305,120 @@ export default function WatchlistDetailScreen() {
);
}
// TV layout with ScrollView + flexWrap
if (Platform.isTV) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
>
{/* Header */}
<View
style={{
alignItems: "center",
marginBottom: 32,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.1)",
}}
>
{watchlist.description && (
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
marginBottom: 16,
textAlign: "center",
}}
>
{watchlist.description}
</Text>
)}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 24,
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons name='film-outline' size={20} color='#9ca3af' />
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{items?.length ?? 0}{" "}
{(items?.length ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons
name={
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
}
size={20}
color='#9ca3af'
/>
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{watchlist.isPublic
? t("watchlists.public")
: t("watchlists.private")}
</Text>
</View>
{!isOwner && (
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
{t("watchlists.by_owner")}
</Text>
)}
</View>
</View>
{/* Grid with flexWrap */}
{!items || items.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Ionicons name='film-outline' size={48} color='#4b5563' />
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
textAlign: "center",
marginTop: 16,
}}
>
{t("watchlists.empty_watchlist")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{items.map((item, index) => renderTVItem(item, index))}
</View>
)}
</ScrollView>
);
}
// Mobile layout with FlashList
return (
<FlashList
key={orientation}
@@ -340,14 +431,13 @@ export default function WatchlistDetailScreen() {
keyExtractor={keyExtractor}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={Platform.isTV ? renderTVItem : renderItem}
renderItem={renderItem}
ItemSeparatorComponent={() => (
<View
style={{

View File

@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { Colors } from "@/constants/Colors";
import { useTVBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -36,6 +37,9 @@ export default function TabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
// Handle TV back button - prevent app exit when at root
useTVBackHandler();
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />

View File

@@ -10,6 +10,7 @@ import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { File } from "expo-file-system";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
@@ -45,10 +46,11 @@ import {
} from "@/modules";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -57,8 +59,8 @@ import {
getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { generateDeviceProfile } from "../../../utils/profiles/native";
export default function page() {
const videoRef = useRef<MpvPlayerViewRef>(null);
@@ -105,6 +107,9 @@ export default function page() {
// when data updates, only when the provider initializes
const downloadedFiles = downloadUtils.getDownloadedItems();
// Inactivity timer controls (TV only)
const { pauseInactivityTimer, resumeInactivityTimer } = useInactivity();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
@@ -228,7 +233,12 @@ export default function page() {
setDownloadedItem(data);
}
} else {
const res = await getUserLibraryApi(api!).getItem({
// Guard against api being null (e.g., during logout)
if (!api) {
setItemStatus({ isLoading: false, isError: false });
return;
}
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
@@ -262,6 +272,7 @@ export default function page() {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
requiredHttpHeaders?: Record<string, string>;
}
const [stream, setStream] = useState<Stream | null>(null);
@@ -324,7 +335,7 @@ export default function page() {
deviceProfile: generateDeviceProfile(),
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(
@@ -333,7 +344,7 @@ export default function page() {
);
return null;
}
result = { mediaSource, sessionId, url };
result = { mediaSource, sessionId, url, requiredHttpHeaders };
}
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
@@ -420,7 +431,9 @@ export default function page() {
setIsPlaybackStopped(true);
videoRef.current?.pause();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
// Resume inactivity timer when leaving player (TV only)
resumeInactivityTimer();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -587,6 +600,13 @@ export default function page() {
autoplay: true,
initialSubtitleId,
initialAudioId,
// Pass cache/buffer settings from user preferences
cacheConfig: {
enabled: settings.mpvCacheEnabled,
cacheSeconds: settings.mpvCacheSeconds,
maxBytes: settings.mpvDemuxerMaxBytes,
maxBackBytes: settings.mpvDemuxerMaxBackBytes,
},
};
// Add external subtitles only for online playback
@@ -594,17 +614,32 @@ export default function page() {
source.externalSubtitles = externalSubs;
}
// Add auth headers only for online streaming (not for local file:// URLs)
if (!offline && api?.accessToken) {
source.headers = {
Authorization: `MediaBrowser Token="${api.accessToken}"`,
};
// Add headers for online streaming (not for local file:// URLs)
if (!offline) {
const headers: Record<string, string> = {};
const isRemoteStream =
mediaSource?.IsRemote && mediaSource?.Protocol === "Http";
// Add auth header only for Jellyfin API requests (not for external/remote streams)
if (api?.accessToken && !isRemoteStream) {
headers.Authorization = `MediaBrowser Token="${api.accessToken}"`;
}
// Add any required headers from the media source (e.g., for external/remote streams)
if (stream?.requiredHttpHeaders) {
Object.assign(headers, stream.requiredHttpHeaders);
}
if (Object.keys(headers).length > 0) {
source.headers = headers;
}
}
return source;
}, [
stream?.url,
stream?.mediaSource,
stream?.requiredHttpHeaders,
item?.UserData?.PlaybackPositionTicks,
playbackPositionFromUrl,
api?.basePath,
@@ -612,6 +647,10 @@ export default function page() {
subtitleIndex,
audioIndex,
offline,
settings.mpvCacheEnabled,
settings.mpvCacheSeconds,
settings.mpvDemuxerMaxBytes,
settings.mpvDemuxerMaxBackBytes,
]);
const volumeUpCb = useCallback(async () => {
@@ -702,6 +741,8 @@ export default function page() {
setIsPlaying(true);
setIsBuffering(false);
setHasPlaybackStarted(true);
// Pause inactivity timer during playback (TV only)
pauseInactivityTimer();
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
@@ -713,6 +754,8 @@ export default function page() {
if (isPaused) {
setIsPlaying(false);
// Resume inactivity timer when paused (TV only)
resumeInactivityTimer();
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
@@ -726,7 +769,13 @@ export default function page() {
setIsBuffering(isLoading);
}
},
[playbackManager, item?.Id, progress],
[
playbackManager,
item?.Id,
progress,
pauseInactivityTimer,
resumeInactivityTimer,
],
);
/** PiP handler for MPV */
@@ -1028,14 +1077,27 @@ export default function page() {
if (settings.mpvSubtitleAlignY !== undefined) {
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
}
if (settings.mpvSubtitleFontSize !== undefined) {
await videoRef.current?.setSubtitleFontSize?.(
settings.mpvSubtitleFontSize,
// Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation)
// mpv uses #RRGGBBAA format (alpha last, same as CSS)
if (settings.mpvSubtitleBackgroundEnabled) {
const opacity = settings.mpvSubtitleBackgroundOpacity ?? 75;
const alphaHex = Math.round((opacity / 100) * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase();
// Enable background-box mode (required for sub-back-color to work)
await videoRef.current?.setSubtitleBorderStyle?.("background-box");
await videoRef.current?.setSubtitleBackgroundColor?.(
`#000000${alphaHex}`,
);
}
// Apply subtitle size from general settings
if (settings.subtitleSize) {
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
// Force override ASS subtitle styles so background shows on styled subtitles
await videoRef.current?.setSubtitleAssOverride?.("force");
} else {
// Restore default outline-and-shadow style
await videoRef.current?.setSubtitleBorderStyle?.("outline-and-shadow");
await videoRef.current?.setSubtitleBackgroundColor?.("#00000000");
// Restore default ASS behavior (keep original styles)
await videoRef.current?.setSubtitleAssOverride?.("no");
}
};
@@ -1056,6 +1118,28 @@ export default function page() {
applyInitialPlaybackSpeed();
}, [isVideoLoaded, initialPlaybackSpeed]);
// TV only: Pre-load locally downloaded subtitles when video loads
// This adds them to MPV's track list without auto-selecting them
useEffect(() => {
if (!Platform.isTV || !isVideoLoaded || !videoRef.current || !itemId)
return;
const preloadLocalSubtitles = async () => {
const localSubs = getSubtitlesForItem(itemId);
for (const sub of localSubs) {
// Verify file still exists (cache may have been cleared)
const subtitleFile = new File(sub.filePath);
if (!subtitleFile.exists) {
continue;
}
// Add subtitle file to MPV without selecting it (select: false)
await videoRef.current?.addSubtitleFile?.(sub.filePath, false);
}
};
preloadLocalSubtitles();
}, [isVideoLoaded, itemId]);
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
return (
@@ -1180,6 +1264,7 @@ export default function page() {
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
downloadedFiles={downloadedFiles}
/>
) : (
<Controls

View File

@@ -17,7 +17,7 @@ import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRo
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
@@ -30,6 +30,7 @@ import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/
import { store } from "@/utils/store";
export default function TVRequestModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation();
@@ -336,8 +337,12 @@ export default function TVRequestModalPage() {
trapFocusRight
style={styles.content}
>
<Text style={styles.heading}>{t("jellyseerr.advanced")}</Text>
<Text style={styles.subtitle}>{modalState.title}</Text>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.advanced")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{isDataLoaded && isReady ? (
<ScrollView
@@ -390,7 +395,12 @@ export default function TVRequestModalPage() {
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text style={styles.buttonText}>
<Text
style={[
styles.buttonText,
{ fontSize: typography.callout },
]}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
@@ -451,13 +461,11 @@ const styles = StyleSheet.create({
overflow: "visible",
},
heading: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
@@ -482,7 +490,6 @@ const styles = StyleSheet.create({
marginTop: 24,
},
buttonText: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},

View File

@@ -16,7 +16,7 @@ import {
import { Text } from "@/components/common/Text";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
@@ -162,6 +162,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
};
export default function TVSeasonSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeasonSelectModalAtom);
const { t } = useTranslation();
@@ -305,8 +306,12 @@ export default function TVSeasonSelectModalPage() {
trapFocusRight
style={styles.content}
>
<Text style={styles.heading}>{t("jellyseerr.select_seasons")}</Text>
<Text style={styles.subtitle}>{modalState.title}</Text>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.select_seasons")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{/* Season cards horizontal scroll */}
<ScrollView
@@ -343,7 +348,9 @@ export default function TVSeasonSelectModalPage() {
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text style={styles.buttonText}>
<Text
style={[styles.buttonText, { fontSize: typography.callout }]}
>
{t("jellyseerr.request_selected")}
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
</Text>
@@ -377,13 +384,11 @@ const styles = StyleSheet.create({
overflow: "visible",
},
heading: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
@@ -413,7 +418,6 @@ const styles = StyleSheet.create({
flex: 1,
},
seasonTitle: {
fontSize: TVTypography.callout,
fontWeight: "600",
marginBottom: 4,
},
@@ -436,7 +440,6 @@ const styles = StyleSheet.create({
marginTop: 24,
},
buttonText: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},

View File

@@ -12,12 +12,13 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVCancelButton, TVOptionCard } from "@/components/tv";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
import { store } from "@/utils/store";
export default function TVSeriesSeasonModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
const { t } = useTranslation();
@@ -103,7 +104,9 @@ export default function TVSeriesSeasonModalPage() {
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>{t("item_card.select_season")}</Text>
<Text style={[styles.title, { fontSize: typography.callout }]}>
{t("item_card.select_season")}
</Text>
{isReady && (
<ScrollView
@@ -164,7 +167,6 @@ const styles = StyleSheet.create({
overflow: "visible",
},
title: {
fontSize: TVTypography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,

View File

@@ -659,8 +659,30 @@ export default function TVSubtitleModal() {
// Do NOT close modal - user can see and select the new track
} else if (downloadResult.type === "local" && downloadResult.path) {
// Notify parent that a local subtitle was downloaded
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
handleClose(); // Only close for local downloads
// Check if component is still mounted after callback
if (!isMountedRef.current) return;
// Refresh tracks to include the newly downloaded subtitle
if (modalState?.refreshSubtitleTracks) {
const newTracks = await modalState.refreshSubtitleTracks();
// Check if component is still mounted after fetching tracks
if (!isMountedRef.current) return;
// Update atom with new tracks
store.set(tvSubtitleModalAtom, {
...modalState,
subtitleTracks: newTracks,
});
// Switch to tracks tab to show the new subtitle
setActiveTab("tracks");
} else {
// No refreshSubtitleTracks available (e.g., from player), just close
handleClose();
}
}
} catch (error) {
console.error("Failed to download subtitle:", error);
@@ -685,13 +707,17 @@ export default function TVSubtitleModal() {
value: -1,
selected: currentSubtitleIndex === -1,
setTrack: () => modalState?.onDisableSubtitles?.(),
isLocal: false,
};
const options = subtitleTracks.map((track: Track) => ({
label: track.name,
sublabel: undefined as string | undefined,
sublabel: track.isLocal
? t("player.downloaded") || "Downloaded"
: (undefined as string | undefined),
value: track.index,
selected: track.index === currentSubtitleIndex,
setTrack: track.setTrack,
isLocal: track.isLocal ?? false,
}));
return [noneOption, ...options];
}, [subtitleTracks, currentSubtitleIndex, t, modalState]);
@@ -905,8 +931,8 @@ export default function TVSubtitleModal() {
<View style={styles.settingRow}>
<TVStepperControl
value={settings.mpvSubtitleScale ?? 1.0}
min={0.5}
max={2.0}
min={0.1}
max={3.0}
step={0.1}
formatValue={(v) => `${v.toFixed(1)}x`}
onChange={(newValue) => {

View File

@@ -0,0 +1,174 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVUserCard } from "@/components/tv/TVUserCard";
import useRouter from "@/hooks/useAppRouter";
import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
import type { SavedServerAccount } from "@/utils/secureCredentials";
import { store } from "@/utils/store";
export default function TVUserSwitchModalPage() {
const { t } = useTranslation();
const router = useRouter();
const modalState = useAtomValue(tvUserSwitchModalAtom);
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount and cleanup atom on unmount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
// Delay focus setup to allow layout
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
// Clear the atom on unmount to prevent stale callbacks from being retained
store.set(tvUserSwitchModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Request focus on the first card when ready
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
const handleSelect = (account: SavedServerAccount) => {
modalState?.onAccountSelect(account);
store.set(tvUserSwitchModalAtom, null);
router.back();
};
// If no modal state, just return null
if (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>
{t("home.settings.switch_user.title")}
</Text>
<Text style={styles.subtitle}>{modalState.serverName}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{modalState.accounts.map((account, index) => {
const isCurrent = account.userId === modalState.currentUserId;
return (
<TVUserCard
key={account.userId}
ref={index === 0 ? firstCardRef : undefined}
username={account.username}
securityType={account.securityType}
hasTVPreferredFocus={index === 0}
isCurrent={isCurrent}
onPress={() => handleSelect(account)}
/>
);
})}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 4,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
subtitle: {
fontSize: 14,
color: "rgba(255,255,255,0.4)",
marginBottom: 16,
paddingHorizontal: 48,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 16,
},
});

View File

@@ -10,10 +10,11 @@ import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { InactivityProvider } from "@/providers/InactivityProvider";
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import {
apiAtom,
@@ -55,15 +56,31 @@ import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native";
import { Appearance, LogBox } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
// Suppress harmless tvOS warning from react-native-gesture-handler
if (Platform.isTV) {
LogBox.ignoreLogs(["HoverGestureHandler is not supported on tvOS"]);
}
import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider";
import { store as jotaiStore, store } from "@/utils/store";
import "react-native-reanimated";
import {
configureReanimatedLogger,
ReanimatedLogLevel,
} from "react-native-reanimated";
import { Toaster } from "sonner-native";
// Disable strict mode warnings for reading shared values during render
configureReanimatedLogger({
level: ReanimatedLogLevel.warn,
strict: false,
});
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
@@ -233,6 +250,11 @@ function Layout() {
const _segments = useSegments();
const router = useRouter();
// Enable TV menu key interception so React Native handles it instead of tvOS
useEffect(() => {
enableTVMenuKeyInterception();
}, []);
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
@@ -253,22 +275,19 @@ function Layout() {
deviceId: getOrSetDeviceId(),
userId: user.Id,
})
.then((_) => console.log("Posted expo push token"))
.catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"),
);
} else console.log("No token available");
}
}, [api, expoPushToken, user]);
const registerNotifications = useCallback(async () => {
if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
// Create dedicated channel for download notifications
console.log("Setting android notification channel 'downloads'");
await Notifications?.setNotificationChannelAsync("downloads", {
name: "Downloads",
importance: Notifications.AndroidImportance.DEFAULT,
@@ -383,119 +402,145 @@ function Layout() {
}}
>
<JellyfinProvider>
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
<InactivityProvider>
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</InactivityProvider>
</JellyfinProvider>
</PersistQueryClientProvider>
);

View File

@@ -0,0 +1,251 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
} from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
import { store } from "@/utils/store";
// Action card component
const TVAccountActionCard: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const typography = useScaledTVTypography();
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isDestructive = variant === "destructive";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
height: 60,
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 24,
gap: 12,
}}
>
<Ionicons
name={icon}
size={22}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: typography.callout,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export default function TVAccountActionModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvAccountActionModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvAccountActionModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
const handleLogin = () => {
modalState?.onLogin();
router.back();
};
const handleDelete = () => {
modalState?.onDelete();
router.back();
};
if (!modalState) {
return null;
}
return (
<Animated.View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
width: "100%",
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Account username as title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 4,
paddingHorizontal: 48,
}}
>
{modalState.account.username}
</Text>
{/* Server name as subtitle */}
<Text
style={{
fontSize: typography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
}}
>
{modalState.server.name || modalState.server.address}
</Text>
{/* Horizontal options */}
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
<TVAccountActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={handleLogin}
/>
<TVAccountActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={handleDelete}
/>
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}

View File

@@ -0,0 +1,256 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVUserCard } from "@/components/tv/TVUserCard";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
import { store } from "@/utils/store";
// Action button for bottom sheet
const TVAccountSelectAction: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
onPress: () => void;
}> = ({ label, icon, variant = "default", onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const typography = useScaledTVTypography();
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isDestructive = variant === "destructive";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 14,
minHeight: 72,
gap: 14,
}}
>
<Ionicons
name={icon}
size={22}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: typography.callout,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export default function TVAccountSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvAccountSelectModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(300);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvAccountSelectModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
if (!modalState) {
return null;
}
return (
<Animated.View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
width: "100%",
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 4,
paddingHorizontal: 48,
}}
>
{t("server.select_account")}
</Text>
{/* Server name as subtitle */}
<Text
style={{
fontSize: typography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
}}
>
{modalState.server.name || modalState.server.address}
</Text>
{/* All options in single horizontal row */}
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 20,
gap: 16,
}}
>
{modalState.server.accounts?.map((account, index) => (
<TVUserCard
key={account.userId}
username={account.username}
securityType={account.securityType}
onPress={() => {
modalState.onAccountAction(account);
}}
hasTVPreferredFocus={index === 0}
/>
))}
<TVAccountSelectAction
label={t("server.add_account")}
icon='person-add-outline'
onPress={() => {
modalState.onAddAccount();
router.back();
}}
/>
<TVAccountSelectAction
label={t("server.remove_server")}
icon='trash-outline'
variant='destructive'
onPress={() => {
modalState.onDeleteServer();
router.back();
}}
/>
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}

113
bun.lock
View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "streamyfin",
@@ -24,6 +25,7 @@
"expo": "~54.0.31",
"expo-application": "~7.0.8",
"expo-asset": "~12.0.12",
"expo-av": "^16.0.8",
"expo-background-task": "~1.0.10",
"expo-blur": "~15.0.8",
"expo-brightness": "~14.0.8",
@@ -77,7 +79,6 @@
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-responsive-sizes": "^2.1.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.18.0",
"react-native-svg": "15.12.1",
@@ -562,8 +563,6 @@
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.79.7", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-CPJ995n1WIyi7KeLj+/aeFCe6MWQrRRXfMvBnc7XP4noSa4WEJfH8Zcvl/iWYVxrQdIaInadoiYLakeSflz5jg=="],
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
@@ -804,10 +803,6 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"caller-callsite": ["caller-callsite@2.0.0", "", { "dependencies": { "callsites": "^2.0.0" } }, "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ=="],
"caller-path": ["caller-path@2.0.0", "", { "dependencies": { "caller-callsite": "^2.0.0" } }, "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
@@ -1014,6 +1009,8 @@
"expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="],
"expo-av": ["expo-av@16.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ=="],
"expo-background-task": ["expo-background-task@1.0.10", "", { "dependencies": { "expo-task-manager": "~14.0.9" }, "peerDependencies": { "expo": "*" } }, "sha512-EbPnuf52Ps/RJiaSFwqKGT6TkvMChv7bI0wF42eADbH3J2EMm5y5Qvj0oFmF1CBOwc3mUhqj63o7Pl6OLkGPZQ=="],
"expo-blur": ["expo-blur@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w=="],
@@ -1246,8 +1243,6 @@
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-directory": ["is-directory@0.3.1", "", {}, "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -1326,8 +1321,6 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -1696,8 +1689,6 @@
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
"react-native-responsive-sizes": ["react-native-responsive-sizes@2.1.0", "", { "dependencies": { "react-native": "^0.79.2" } }, "sha512-uxWi0IDj8CBGRh6KJyQ2RagWmLTWPWF5sDnVpM4jt/khwhEdaUeGa/q9rHcVHbb4o+oo1Zei9P3zIwbFc1UGcw=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
"react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="],
@@ -2292,8 +2283,6 @@
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"caller-callsite/callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -2446,8 +2435,6 @@
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"react-native-responsive-sizes/react-native": ["react-native@0.79.7", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.79.7", "@react-native/codegen": "0.79.7", "@react-native/community-cli-plugin": "0.79.7", "@react-native/gradle-plugin": "0.79.7", "@react-native/js-polyfills": "0.79.7", "@react-native/normalize-colors": "0.79.7", "@react-native/virtualized-lists": "0.79.7", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.82.0", "metro-source-map": "^0.82.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.25.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "^19.0.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-7B2FJt/P+qulrkjWNttofiQjpZ5czSnL00kr6kQ9GpiykF/agX6Z2GVX6e5ggpQq2jqtyLvRtHIiUnKPYM77+w=="],
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
@@ -2994,30 +2981,6 @@
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"react-native-responsive-sizes/react-native/@react-native/assets-registry": ["@react-native/assets-registry@0.79.7", "", {}, "sha512-YeOXq8H5JZQbeIcAtHxmboDt02QG8ej8Z4SFVNh5UjaSb/0X1/v5/DhwNb4dfpIsQ5lFy75jeoSmUVp8qEKu9g=="],
"react-native-responsive-sizes/react-native/@react-native/codegen": ["@react-native/codegen@0.79.7", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.25.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-uOjsqpLccl0+8iHPBmrkFrWwK0ctW28M83Ln2z43HRNubkxk5Nxd3DoyphFPL/BwTG79Ixu+BqpCS7b9mtizpw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.79.7", "", { "dependencies": { "@react-native/dev-middleware": "0.79.7", "chalk": "^4.0.0", "debug": "^2.2.0", "invariant": "^2.2.4", "metro": "^0.82.0", "metro-config": "^0.82.0", "metro-core": "^0.82.0", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*" }, "optionalPeers": ["@react-native-community/cli"] }, "sha512-UQADqWfnKfEGMIyOa1zI8TMAOOLDdQ3h2FTCG8bp+MFGLAaJowaa+4GGb71A26fbg06/qnGy/Kr0Mv41IFGZnQ=="],
"react-native-responsive-sizes/react-native/@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.79.7", "", {}, "sha512-vQqVthSs2EGqzV4KI0uFr/B4hUVXhVM86ekYL8iZCXzO6bewZa7lEUNGieijY0jc0a/mBJ6KZDzMtcUoS5vFRA=="],
"react-native-responsive-sizes/react-native/@react-native/js-polyfills": ["@react-native/js-polyfills@0.79.7", "", {}, "sha512-Djgvfz6AOa8ZEWyv+KA/UnP+ZruM+clCauFTR6NeRyD8YELvXGt+6A231SwpNdRkM7aTDMv0cM0NUbAMEPy+1A=="],
"react-native-responsive-sizes/react-native/@react-native/normalize-colors": ["@react-native/normalize-colors@0.79.7", "", {}, "sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ=="],
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.25.1", "", { "dependencies": { "hermes-parser": "0.25.1" } }, "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ=="],
"react-native-responsive-sizes/react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"react-native-responsive-sizes/react-native/metro-runtime": ["metro-runtime@0.82.5", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-rQZDoCUf7k4Broyw3Ixxlq5ieIPiR1ULONdpcYpbJQ6yQ5GGEyYjtkztGD+OhHlw81LCR2SUAoPvtTus2WDK5g=="],
"react-native-responsive-sizes/react-native/metro-source-map": ["metro-source-map@0.82.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.82.5", "nullthrows": "^1.1.1", "ob1": "0.82.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-wH+awTOQJVkbhn2SKyaw+0cd+RVSCZ3sHVgyqJFQXIee/yLs3dZqKjjeKKhhVeudgjXo7aE/vSu/zVfcQEcUfw=="],
"react-native-responsive-sizes/react-native/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
"react-native-responsive-sizes/react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -3202,26 +3165,6 @@
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.79.7", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.79.7", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-KHGPa7xwnKKWrzMnV1cHc8J56co4tFevmRvbjEbUCqkGS0s/l8ZxAGMR222/6YxZV3Eg1J3ywKQ8nHzTsTz5jw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro": ["metro@0.82.5", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-config": "0.82.5", "metro-core": "0.82.5", "metro-file-map": "0.82.5", "metro-resolver": "0.82.5", "metro-runtime": "0.82.5", "metro-source-map": "0.82.5", "metro-symbolicate": "0.82.5", "metro-transform-plugins": "0.82.5", "metro-transform-worker": "0.82.5", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8oAXxL7do8QckID/WZEKaIFuQJFUTLzfVcC48ghkHhNK2RGuQq8Xvf4AVd+TUA0SZtX0q8TGNXZ/eba1ckeGCg=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config": ["metro-config@0.82.5", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.82.5", "metro-cache": "0.82.5", "metro-core": "0.82.5", "metro-runtime": "0.82.5" } }, "sha512-/r83VqE55l0WsBf8IhNmc/3z71y2zIPe5kRSuqA5tY/SL/ULzlHUJEMd1szztd0G45JozLwjvrhAzhDPJ/Qo/g=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core": ["metro-core@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.82.5" } }, "sha512-OJL18VbSw2RgtBm1f2P3J5kb892LCVJqMvslXxuxjAPex8OH7Eb8RBfgEo7VZSjgb/LOf4jhC4UFk5l5tAOHHA=="],
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"react-native-responsive-sizes/react-native/metro-source-map/@babel/traverse--for-generate-function-map": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
"react-native-responsive-sizes/react-native/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="],
"react-native-responsive-sizes/react-native/metro-source-map/ob1": ["ob1@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -3258,42 +3201,6 @@
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.79.7", "", {}, "sha512-91JVlhR6hDuJXcWTpCwcdEPlUQf+TckNG8BYfR4UkUOaZ87XahJv4EyWBeyfd8lwB/mh6nDJqbR6UiXwt5kbog=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.29.1", "nullthrows": "^1.1.1" } }, "sha512-W/scFDnwJXSccJYnOFdGiYr9srhbHPdxX9TvvACOFsIXdLilh3XuxQl/wXW6jEJfgIb0jTvoTlwwrqvuwymr6Q=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-qpVmPbDJuRLrT4kcGlUouyqLGssJnbTllVtvIgXfR7ZuzMKf0mGS+8WzcqzNK8+kCyakombQWR0uDd8qhWGJcA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.82.5", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-vpMDxkGIB+MTN8Af5hvSAanc6zXQipsAUO+XUx3PCQieKUfLwdoa8qaZ1WAQYRpaU+CJ8vhBcxtzzo3d9IsCIQ=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-57Bqf3rgq9nPqLrT2d9kf/2WVieTFqsQ6qWHpEng5naIUtc/Iiw9+0bfLLWSAw0GH40iJ4yMjFcFJDtNSYynMA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.82.5", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-minify-terser": "0.82.5", "metro-source-map": "0.82.5", "metro-transform-plugins": "0.82.5", "nullthrows": "^1.1.1" } }, "sha512-mx0grhAX7xe+XUQH6qoHHlWedI8fhSpDGsfga7CpkO9Lk9W+aPitNtJWNGrW8PfjKEWbT9Uz9O50dkI8bJqigw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig": ["cosmiconfig@5.2.1", "", { "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", "js-yaml": "^3.13.1", "parse-json": "^4.0.0" } }, "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="],
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
@@ -3306,18 +3213,6 @@
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh": ["import-fresh@2.0.0", "", { "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" } }, "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-v6Nx7A4We6PqPu/ta1oGTqJ4Usz0P7c+3XNeBxW9kp8zayS3lHUKR0sY0wsCHInxZlNAEICx791x+uXytFUuwg=="],
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh/resolve-from": ["resolve-from@3.0.0", "", {}, "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
}
}

View File

@@ -1,7 +1,7 @@
import { BlurView } from "expo-blur";
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text";
interface Props extends ViewProps {
@@ -16,6 +16,8 @@ export const Badge: React.FC<Props> = ({
variant = "purple",
...props
}) => {
const typography = useScaledTVTypography();
const content = (
<View style={styles.content}>
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
@@ -69,7 +71,7 @@ export const Badge: React.FC<Props> = ({
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#E5E7EB",
}}
>

View File

@@ -14,7 +14,6 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { fontSize, size } from "react-native-responsive-sizes";
import { useHaptic } from "@/hooks/useHaptic";
import { Loader } from "./Loader";
@@ -133,7 +132,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<Animated.View
style={{
transform: [{ scale }],
shadowColor: color === "black" ? "#ffffff" : "#a855f7",
shadowColor: "#ffffff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 10 : 0,
@@ -141,15 +140,11 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
}}
>
<View
className={`rounded-2xl items-center justify-center
className={`rounded-2xl py-5 items-center justify-center
${colorClasses}
${className}`}
style={{ paddingVertical: size(50) }}
>
<Text
className={`${textColorClass} font-bold`}
style={{ fontSize: fontSize(10) }}
>
<Text className={`${textColorClass} text-xl font-bold`}>
{children}
</Text>
</View>

View File

@@ -1,128 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator";
export const TV_LANDSCAPE_WIDTH = 340;
type ContinueWatchingPosterProps = {
item: BaseItemDto;
useEpisodePoster?: boolean;
size?: "small" | "normal";
showPlayButton?: boolean;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
useEpisodePoster = false,
// TV version uses fixed width, size prop kept for API compatibility
size: _size = "normal",
showPlayButton = false,
}) => {
const api = useAtomValue(apiAtom);
const url = useMemo(() => {
if (!api) {
return;
}
if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.Type === "Movie") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}, [api, item, useEpisodePoster]);
if (!url) {
return (
<View
style={{
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
}}
/>
);
}
return (
<View
style={{
position: "relative",
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
width: "100%",
height: "100%",
}}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
<WatchedIndicator item={item} />
<ProgressBar item={item} />
</View>
);
};
export default ContinueWatchingPoster;

View File

@@ -73,12 +73,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
SelectedOptions | undefined
>(undefined);
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(items[0], settings);
} = useDefaultPlaySettings(items[0], settings, playSettingsOptions);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,

View File

@@ -10,7 +10,7 @@ import {
type ViewProps,
} from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text";
interface TagProps {
@@ -25,6 +25,9 @@ export const Tag: React.FC<
textStyle?: StyleProp<TextStyle>;
} & ViewProps
> = ({ text, textClass, textStyle, ...props }) => {
// Hook must be called at the top level, before any conditional returns
const typography = useScaledTVTypography();
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<View>
@@ -60,7 +63,7 @@ export const Tag: React.FC<
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
<Text style={{ fontSize: TVTypography.callout, color: "#E5E7EB" }}>
<Text style={{ fontSize: typography.callout, color: "#E5E7EB" }}>
{text}
</Text>
</View>

View File

@@ -75,12 +75,20 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
>(undefined);
// Use itemWithSources for play settings since it has MediaSources data
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
} = useDefaultPlaySettings(
itemWithSources ?? item,
settings,
playSettingsOptions,
);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),

View File

@@ -4,9 +4,10 @@ import type {
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQueryClient } from "@tanstack/react-query";
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { File } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, {
@@ -17,12 +18,14 @@ import React, {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
import { Alert, Dimensions, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { GenreTags } from "@/components/GenreTags";
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
import {
TVBackdrop,
TVButton,
@@ -31,25 +34,29 @@ import {
TVFavoriteButton,
TVMetadataBadges,
TVOptionButton,
TVPlayedButton,
TVProgressBar,
TVRefreshButton,
TVSeriesNavigation,
TVTechnicalDetails,
} from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { runtimeTicksToMinutes } from "@/utils/time";
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -69,27 +76,67 @@ interface ItemContentTVProps {
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
({ item, itemWithSources }) => {
const typography = useScaledTVTypography();
const [api] = useAtom(apiAtom);
const [_user] = useAtom(userAtom);
const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const { t } = useTranslation();
const queryClient = useQueryClient();
const _itemColors = useImageColorsReturn({ item });
// Auto-play theme music (handles fade in/out and cleanup)
useTVThemeMusic(item?.Id);
// State for first episode card ref (used for focus guide)
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
// Fetch season episodes for episodes
const { data: seasonEpisodes = [] } = useQuery({
queryKey: ["episodes", item?.SeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId,
userId: user.Id,
seasonId: item.SeasonId,
enableUserData: true,
fields: ["MediaSources", "Overview"],
});
return res.data.Items || [];
},
enabled:
!!api &&
!!user?.Id &&
!!item?.SeriesId &&
!!item?.SeasonId &&
item?.Type === "Episode",
});
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
// Enable language preference application for TV
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
} = useDefaultPlaySettings(
itemWithSources ?? item,
settings,
playSettingsOptions,
);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
@@ -111,21 +158,59 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
defaultMediaSource,
]);
const navigateToPlayer = useCallback(
(playbackPosition: string) => {
if (!item || !selectedOptions) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition,
offline: isOffline ? "true" : "false",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
},
[item, selectedOptions, isOffline, router],
);
const handlePlay = () => {
if (!item || !selectedOptions) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
const hasPlaybackProgress =
(item.UserData?.PlaybackPositionTicks ?? 0) > 0;
router.push(`/player/direct-player?${queryParams.toString()}`);
if (hasPlaybackProgress) {
Alert.alert(
t("item_card.resume_playback"),
t("item_card.resume_playback_description"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("item_card.play_from_start"),
onPress: () => navigateToPlayer("0"),
},
{
text: t("item_card.continue_from", {
time: formatDuration(item.UserData?.PlaybackPositionTicks),
}),
onPress: () =>
navigateToPlayer(
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
),
isPreferred: true,
},
],
);
} else {
navigateToPlayer("0");
}
};
// TV Option Modal hook for quality, audio, media source selectors
@@ -135,12 +220,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const { showSubtitleModal } = useTVSubtitleModal();
// State for first actor card ref (used for focus guide)
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
null,
);
// State for last option button ref (used for upward focus guide from cast)
const [lastOptionButtonRef, setLastOptionButtonRef] = useState<View | null>(
const [_firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
null,
);
@@ -165,9 +245,16 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
null,
);
// State to trigger refresh of local subtitles list
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
// Starting index for local (client-downloaded) subtitles
const LOCAL_SUBTITLE_INDEX_START = -100;
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
// Also includes locally downloaded subtitles from OpenSubtitles
const subtitleTracksForModal = useMemo((): Track[] => {
return subtitleStreams.map((stream) => ({
const tracks: Track[] = subtitleStreams.map((stream) => ({
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
@@ -176,7 +263,37 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
},
}));
}, [subtitleStreams]);
// Add locally downloaded subtitles (from OpenSubtitles)
if (item?.Id) {
const localSubs = getSubtitlesForItem(item.Id);
let localIdx = 0;
for (const localSub of localSubs) {
// Verify file still exists (cache may have been cleared)
const subtitleFile = new File(localSub.filePath);
if (!subtitleFile.exists) {
continue;
}
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
tracks.push({
name: localSub.name,
index: localIndex,
isLocal: true,
localPath: localSub.filePath,
setTrack: () => {
// For ItemContent (outside player), just update the selected index
// The actual subtitle will be loaded when playback starts
handleSubtitleChangeRef.current?.(localIndex);
},
});
localIdx++;
}
}
return tracks;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]);
// Get available media sources
const mediaSources = useMemo(() => {
@@ -268,6 +385,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}
}, [item?.Id, queryClient]);
// Handle local subtitle download - trigger refresh of subtitle tracks
const handleLocalSubtitleDownloaded = useCallback((_path: string) => {
// Increment the refresh key to trigger re-computation of subtitleTracksForModal
setLocalSubtitlesRefreshKey((prev) => prev + 1);
}, []);
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
if (!api || !item?.Id) return [];
@@ -295,7 +418,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
) ?? [];
// Convert to Track[] with setTrack callbacks
return streams.map((stream) => ({
const tracks: Track[] = streams.map((stream) => ({
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
@@ -304,6 +427,30 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
},
}));
// Add locally downloaded subtitles
if (item?.Id) {
const localSubs = getSubtitlesForItem(item.Id);
let localIdx = 0;
for (const localSub of localSubs) {
const subtitleFile = new File(localSub.filePath);
if (!subtitleFile.exists) continue;
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
tracks.push({
name: localSub.name,
index: localIndex,
isLocal: true,
localPath: localSub.filePath,
setTrack: () => {
handleSubtitleChangeRef.current?.(localIndex);
},
});
localIdx++;
}
}
return tracks;
} catch (error) {
console.error("Failed to refresh subtitle tracks:", error);
return [];
@@ -321,13 +468,30 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const selectedSubtitleLabel = useMemo(() => {
if (selectedOptions?.subtitleIndex === -1)
return t("item_card.subtitles.none");
// Check if it's a local subtitle (negative index starting at -100)
if (
selectedOptions?.subtitleIndex !== undefined &&
selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START
) {
const localTrack = subtitleTracksForModal.find(
(t) => t.index === selectedOptions.subtitleIndex,
);
return localTrack?.name || t("item_card.subtitles.label");
}
const track = subtitleStreams.find(
(t) => t.Index === selectedOptions?.subtitleIndex,
);
return (
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
);
}, [subtitleStreams, selectedOptions?.subtitleIndex, t]);
}, [
subtitleStreams,
subtitleTracksForModal,
selectedOptions?.subtitleIndex,
t,
]);
const selectedMediaSourceLabel = useMemo(() => {
const source = selectedOptions?.mediaSource;
@@ -386,24 +550,17 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
// Determine which option button is the last one (for focus guide targeting)
const lastOptionButton = useMemo(() => {
const hasSubtitleOption =
subtitleStreams.length > 0 ||
selectedOptions?.subtitleIndex !== undefined;
const hasAudioOption = audioTracks.length > 0;
const hasMediaSourceOption = mediaSources.length > 1;
// Episode thumbnail URL - episode's own primary image (16:9 for episodes)
const episodeThumbnailUrl = useMemo(() => {
if (item?.Type !== "Episode" || !api) return null;
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}, [api, item]);
if (hasSubtitleOption) return "subtitle";
if (hasAudioOption) return "audio";
if (hasMediaSourceOption) return "mediaSource";
return "quality";
}, [
subtitleStreams.length,
selectedOptions?.subtitleIndex,
audioTracks.length,
mediaSources.length,
]);
// Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled
const seriesThumbUrl = useMemo(() => {
if (item?.Type !== "Episode" || !item.SeriesId || !api) return null;
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
}, [api, item]);
// Navigation handlers
const handleActorPress = useCallback(
@@ -427,6 +584,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
const handleEpisodePress = useCallback(
(episode: BaseItemDto) => {
const navigation = getItemNavigation(episode, "(home)");
router.replace(navigation as any);
},
[router],
);
if (!item || !selectedOptions) return null;
return (
@@ -456,36 +621,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Poster */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
<ItemImage
variant='Primary'
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
</View>
{/* Right side - Content */}
{/* Left side - Content */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo or Title */}
{logoUrl ? (
@@ -502,7 +638,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
) : (
<Text
style={{
fontSize: TVTypography.display,
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 20,
@@ -518,7 +654,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
<View style={{ marginBottom: 16 }}>
<Text
style={{
fontSize: TVTypography.title,
fontSize: typography.title,
color: "#FFFFFF",
fontWeight: "600",
}}
@@ -527,7 +663,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "white",
marginTop: 6,
}}
@@ -572,7 +708,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#E5E7EB",
lineHeight: 32,
}}
@@ -605,7 +741,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "bold",
color: "#000000",
}}
@@ -616,27 +752,24 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text>
</TVButton>
<TVFavoriteButton item={item} />
<TVPlayedButton item={item} />
<TVRefreshButton itemId={item.Id} />
</View>
{/* Playback options */}
<View
style={{
flexDirection: "column",
alignItems: "flex-start",
gap: 10,
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 24,
}}
>
{/* Quality selector */}
<TVOptionButton
ref={
lastOptionButton === "quality"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.quality")}
value={selectedQualityLabel}
maxWidth={200}
onPress={() =>
showOptions({
title: t("item_card.quality"),
@@ -649,13 +782,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{/* Media source selector (only if multiple sources) */}
{mediaSources.length > 1 && (
<TVOptionButton
ref={
lastOptionButton === "mediaSource"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.video")}
value={selectedMediaSourceLabel}
maxWidth={280}
onPress={() =>
showOptions({
title: t("item_card.video"),
@@ -669,13 +798,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{/* Audio selector */}
{audioTracks.length > 0 && (
<TVOptionButton
ref={
lastOptionButton === "audio"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.audio")}
value={selectedAudioLabel}
maxWidth={280}
onPress={() =>
showOptions({
title: t("item_card.audio"),
@@ -690,13 +815,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{(subtitleStreams.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton
ref={
lastOptionButton === "subtitle"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel}
maxWidth={280}
onPress={() =>
showSubtitleModal({
item,
@@ -707,6 +828,8 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
onDisableSubtitles: () => handleSubtitleChange(-1),
onServerSubtitleDownloaded:
handleServerSubtitleDownloaded,
onLocalSubtitleDownloaded:
handleLocalSubtitleDownloaded,
refreshSubtitleTracks,
})
}
@@ -714,14 +837,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)}
</View>
{/* Focus guide to direct navigation from options to cast list */}
{fullCast.length > 0 && firstActorCardRef && (
<TVFocusGuideView
destinations={[firstActorCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Progress bar (if partially watched) */}
{hasProgress && item.RunTimeTicks != null && (
<TVProgressBar
@@ -733,11 +848,99 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
/>
)}
</View>
{/* Right side - Poster */}
<View
style={{
width:
item.Type === "Episode"
? SCREEN_WIDTH * 0.35
: SCREEN_WIDTH * 0.22,
marginLeft: 50,
}}
>
<View
style={{
aspectRatio: item.Type === "Episode" ? 16 / 9 : 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
{item.Type === "Episode" ? (
<Image
source={{
uri:
settings.showSeriesPosterOnEpisode && seriesThumbUrl
? seriesThumbUrl
: episodeThumbnailUrl!,
}}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<ItemImage
variant='Primary'
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
)}
</View>
</View>
</View>
{/* Additional info section */}
<View style={{ marginTop: 40 }}>
{/* Cast & Crew (text version) */}
{/* Season Episodes - Episode only */}
{item.Type === "Episode" && seasonEpisodes.length > 1 && (
<View style={{ marginBottom: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 24,
}}
>
{t("item_card.more_from_this_season")}
</Text>
<TVEpisodeList
episodes={seasonEpisodes}
currentEpisodeId={item.Id}
onEpisodePress={handleEpisodePress}
onEpisodeLongPress={showItemActions}
firstEpisodeRefSetter={setFirstEpisodeRef}
/>
</View>
)}
{/* From this Series - Episode only */}
<TVSeriesNavigation
item={item}
seriesImageUrl={seriesImageUrl}
seasonImageUrl={seasonImageUrl}
onSeriesPress={handleSeriesPress}
onSeasonPress={handleSeasonPress}
/>
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
{showVisualCast && (
<TVCastSection
cast={fullCast}
apiBasePath={api?.basePath}
onActorPress={handleActorPress}
firstActorRefSetter={setFirstActorCardRef}
/>
)}
{/* Cast & Crew (text version - director, etc.) */}
<TVCastCrewText
director={director}
cast={cast}
@@ -751,26 +954,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
mediaStreams={selectedOptions.mediaSource.MediaStreams}
/>
)}
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
{showVisualCast && (
<TVCastSection
cast={fullCast}
apiBasePath={api?.basePath}
onActorPress={handleActorPress}
firstActorRefSetter={setFirstActorCardRef}
upwardFocusDestination={lastOptionButtonRef}
/>
)}
{/* From this Series - Episode only */}
<TVSeriesNavigation
item={item}
seriesImageUrl={seriesImageUrl}
seasonImageUrl={seasonImageUrl}
onSeriesPress={handleSeriesPress}
onSeasonPress={handleSeasonPress}
/>
</View>
</ScrollView>
</View>

View File

@@ -1,41 +1,28 @@
import React from "react";
import { Dimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
export const ItemContentSkeletonTV: React.FC = () => {
const insets = useSafeAreaInsets();
return (
<View
style={{
flex: 1,
flexDirection: "row",
paddingTop: 180,
paddingHorizontal: 160,
paddingTop: insets.top + 140,
paddingHorizontal: insets.left + 80,
}}
>
{/* Left side - Poster placeholder */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
{/* Left side - Content placeholders */}
<View style={{ flex: 1 }}>
{/* Logo placeholder */}
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
backgroundColor: "#1a1a1a",
}}
/>
</View>
{/* Right side - Content placeholders */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo/Title placeholder */}
<View
style={{
height: 80,
width: "60%",
height: 150,
width: "80%",
backgroundColor: "#1a1a1a",
borderRadius: 8,
marginBottom: 24,
@@ -155,6 +142,22 @@ export const ItemContentSkeletonTV: React.FC = () => {
}}
/>
</View>
{/* Right side - Poster placeholder */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginLeft: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
backgroundColor: "#1a1a1a",
}}
/>
</View>
</View>
);
};

View File

@@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
{/* Password Input */}
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
<Text className='text-neutral-400 text-sm mb-2'>
{t("login.password")}
{t("login.password_placeholder")}
</Text>
<BottomSheetTextInput
value={password}
@@ -136,7 +136,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
setPassword(text);
setError(null);
}}
placeholder={t("login.password")}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
secureTextEntry
autoFocus
@@ -174,7 +174,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
{isLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("login.login")
t("common.login")
)}
</Button>
</View>

View File

@@ -35,9 +35,9 @@ import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import { chromecast } from "../utils/profiles/chromecast";
import { chromecasth265 } from "../utils/profiles/chromecasth265";
import { Button } from "./Button";
import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent";

View File

@@ -73,10 +73,19 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
setLoadingServer(server.address);
try {
await onQuickLogin(server.address, account.userId);
} catch {
Alert.alert(
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
t("server.please_login_again"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
);
} finally {
@@ -122,10 +131,17 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
setLoadingServer(selectedServer.address);
try {
await onQuickLogin(selectedServer.address, selectedAccount.userId);
} catch {
Alert.alert(
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
t("server.please_login_again"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[
{
text: t("common.ok"),

View File

@@ -1,909 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getTvShowsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Pressable,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
Easing,
interpolate,
runOnJS,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { ItemImage } from "../common/ItemImage";
import { getItemNavigation } from "../common/TouchableItemRouter";
import type { SelectedOptions } from "../ItemContent";
import { PlayButton } from "../PlayButton";
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
interface AppleTVCarouselProps {
initialIndex?: number;
onItemChange?: (index: number) => void;
scrollOffset?: SharedValue<number>;
}
// Layout Constants
const GRADIENT_HEIGHT_TOP = 150;
const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80;
// Position Constants
const LOGO_BOTTOM_POSITION = 260;
const GENRES_BOTTOM_POSITION = 220;
const OVERVIEW_BOTTOM_POSITION = 165;
const CONTROLS_BOTTOM_POSITION = 80;
const DOTS_BOTTOM_POSITION = 40;
// Size Constants
const DOT_HEIGHT = 6;
const DOT_ACTIVE_WIDTH = 20;
const DOT_INACTIVE_WIDTH = 12;
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250;
const OVERVIEW_SKELETON_HEIGHT = 16;
const OVERVIEW_SKELETON_WIDTH = 400;
const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants
const HORIZONTAL_PADDING = 40;
const DOT_PADDING = 2;
const DOT_GAP = 4;
const CONTROLS_GAP = 10;
const _TEXT_MARGIN_TOP = 16;
// Border Radius Constants
const DOT_BORDER_RADIUS = 3;
const LOGO_SKELETON_BORDER_RADIUS = 8;
const TEXT_SKELETON_BORDER_RADIUS = 4;
const PLAY_BUTTON_BORDER_RADIUS = 25;
const PLAYED_STATUS_BORDER_RADIUS = 20;
// Animation Constants
const DOT_ANIMATION_DURATION = 300;
const CAROUSEL_TRANSITION_DURATION = 250;
const PAN_ACTIVE_OFFSET = 10;
const TRANSLATION_THRESHOLD = 0.2;
const VELOCITY_THRESHOLD = 400;
// Text Constants
const GENRES_FONT_SIZE = 16;
const OVERVIEW_FONT_SIZE = 14;
const _EMPTY_STATE_FONT_SIZE = 18;
const TEXT_SHADOW_RADIUS = 2;
const MAX_GENRES_COUNT = 2;
const MAX_BUTTON_WIDTH = 300;
const OVERVIEW_MAX_LINES = 2;
const OVERVIEW_MAX_WIDTH = "80%";
// Opacity Constants
const OVERLAY_OPACITY = 0.3;
const DOT_INACTIVE_OPACITY = 0.6;
const TEXT_OPACITY = 0.9;
// Color Constants
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
const SKELETON_ELEMENT_COLOR = "#333";
const SKELETON_ACTIVE_DOT_COLOR = "#666";
const _EMPTY_STATE_COLOR = "#666";
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
const LOGO_WIDTH_PERCENTAGE = "80%";
const DotIndicator = ({
index,
currentIndex,
onPress,
}: {
index: number;
currentIndex: number;
onPress: (index: number) => void;
}) => {
const isActive = index === currentIndex;
const animatedStyle = useAnimatedStyle(() => ({
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
}));
return (
<Pressable
onPress={() => onPress(index)}
style={{
padding: DOT_PADDING, // Increase touch area
}}
>
<Animated.View
style={[
{
height: DOT_HEIGHT,
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
borderRadius: DOT_BORDER_RADIUS,
},
animatedStyle,
]}
/>
</Pressable>
);
};
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
initialIndex = 0,
onItemChange,
scrollOffset,
}) => {
const { settings } = useSettings();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { isConnected, serverConnected } = useNetworkStatus();
const router = useRouter();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const isLandscape = screenWidth >= screenHeight;
const carouselHeight = useMemo(
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
[isLandscape, screenHeight],
);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const translateX = useSharedValue(-initialIndex * screenWidth);
const isQueryEnabled =
!!api && !!user?.Id && isConnected && serverConnected === true;
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
useQuery({
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres", "Overview"],
limit: 2,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getTvShowsApi(api).getNextUp({
userId: user.Id,
fields: ["MediaSourceCount", "Genres", "Overview"],
limit: 2,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
{
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getUserLibraryApi(api).getLatestMedia({
userId: user.Id,
limit: 2,
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
});
return response.data || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
},
);
const items = useMemo(() => {
const continueItems = continueWatchingData ?? [];
const nextItems = nextUpData ?? [];
const recentItems = recentlyAddedData ?? [];
const allItems = [
...continueItems.slice(0, 2),
...nextItems.slice(0, 2),
...recentItems.slice(0, 2),
];
// Deduplicate by item ID to prevent duplicate keys
const seen = new Set<string>();
return allItems.filter((item) => {
if (item.Id && !seen.has(item.Id)) {
seen.add(item.Id);
return true;
}
return false;
});
}, [continueWatchingData, nextUpData, recentlyAddedData]);
const isLoading =
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
const hasItems = items.length > 0;
// Only get play settings if we have valid items
const currentItem = hasItems ? items[currentIndex] : null;
// Extract colors for the current item only (for performance)
const currentItemColors = useImageColorsReturn({ item: currentItem });
// Create a fallback empty item for useDefaultPlaySettings when no item is available
const itemForPlaySettings = currentItem || { MediaSources: [] };
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
useEffect(() => {
// Only set options if we have valid current item
if (currentItem) {
setSelectedOptions({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
});
} else {
setSelectedOptions(undefined);
}
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
currentIndex,
currentItem,
]);
useEffect(() => {
if (!hasItems) {
setCurrentIndex(initialIndex);
translateX.value = -initialIndex * screenWidth;
return;
}
setCurrentIndex((prev) => {
const newIndex = Math.min(prev, items.length - 1);
translateX.value = -newIndex * screenWidth;
return newIndex;
});
}, [hasItems, items, initialIndex, screenWidth, translateX]);
useEffect(() => {
translateX.value = -currentIndex * screenWidth;
}, [currentIndex, screenWidth, translateX]);
useEffect(() => {
if (hasItems) {
onItemChange?.(currentIndex);
}
}, [hasItems, currentIndex, onItemChange]);
const goToIndex = useCallback(
(index: number) => {
if (!hasItems || index < 0 || index >= items.length) return;
translateX.value = withTiming(-index * screenWidth, {
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
});
setCurrentIndex(index);
onItemChange?.(index);
},
[hasItems, items, onItemChange, screenWidth, translateX],
);
const navigateToItem = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)");
router.push(navigation as any);
},
[router],
);
const panGesture = Gesture.Pan()
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
.onUpdate((event) => {
translateX.value = -currentIndex * screenWidth + event.translationX;
})
.onEnd((event) => {
const velocity = event.velocityX;
const translation = event.translationX;
let newIndex = currentIndex;
// Improved thresholds for more responsive navigation
if (
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
Math.abs(velocity) > VELOCITY_THRESHOLD
) {
if (translation > 0 && currentIndex > 0) {
newIndex = currentIndex - 1;
} else if (
translation < 0 &&
items &&
currentIndex < items.length - 1
) {
newIndex = currentIndex + 1;
}
}
runOnJS(goToIndex)(newIndex);
});
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
};
});
const togglePlayedStatus = useMarkAsPlayed(items);
const headerAnimatedStyle = useAnimatedStyle(() => {
if (!scrollOffset) return {};
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-carouselHeight, 0, carouselHeight],
[-carouselHeight / 2, 0, carouselHeight * 0.75],
),
},
{
scale: interpolate(
scrollOffset.value,
[-carouselHeight, 0, carouselHeight],
[2, 1, 1],
),
},
],
};
});
const renderDots = () => {
if (!hasItems || items.length <= 1) return null;
return (
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{items.map((_, index) => (
<DotIndicator
key={index}
index={index}
currentIndex={currentIndex}
onPress={goToIndex}
/>
))}
</View>
);
};
const renderSkeletonLoader = () => {
return (
<View
style={{
width: screenWidth,
height: carouselHeight,
backgroundColor: "#000",
}}
>
{/* Background Skeleton */}
<View
style={{
width: "100%",
height: "100%",
backgroundColor: SKELETON_BACKGROUND_COLOR,
position: "absolute",
}}
/>
{/* Dark Overlay Skeleton */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black Top Skeleton */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black Bottom Skeleton */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Skeleton */}
<View
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Type and Genres Skeleton */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: TEXT_SKELETON_HEIGHT,
width: TEXT_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Overview Skeleton */}
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
gap: 6,
}}
>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH * 0.7,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Controls Skeleton */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button Skeleton */}
<View
style={{
height: PLAY_BUTTON_SKELETON_HEIGHT,
flex: 1,
maxWidth: MAX_BUTTON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
}}
/>
{/* Played Status Skeleton */}
<View
style={{
width: PLAYED_STATUS_SKELETON_SIZE,
height: PLAYED_STATUS_SKELETON_SIZE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
}}
/>
</View>
{/* Dots Skeleton */}
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{[1, 2, 3].map((_, index) => (
<View
key={index}
style={{
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
height: DOT_HEIGHT,
backgroundColor:
index === 0
? SKELETON_ACTIVE_DOT_COLOR
: SKELETON_ELEMENT_COLOR,
borderRadius: DOT_BORDER_RADIUS,
}}
/>
))}
</View>
</View>
);
};
const renderItem = (item: BaseItemDto, _index: number) => {
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
return (
<View
key={item.Id}
style={{
width: screenWidth,
height: carouselHeight,
position: "relative",
}}
>
{/* Background Backdrop */}
<Animated.View
style={[
{
width: "100%",
height: "100%",
position: "absolute",
},
headerAnimatedStyle,
]}
>
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
}}
/>
</Animated.View>
{/* Dark Overlay */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black at Top */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black at Bottom */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Section */}
{itemLogoUrl && (
<TouchableOpacity
onPress={() => navigateToItem(item)}
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<Image
source={{
uri: itemLogoUrl,
}}
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
}}
contentFit='contain'
/>
</TouchableOpacity>
)}
{/* Type and Genres Section */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
fontSize: GENRES_FONT_SIZE,
fontWeight: "500",
textAlign: "center",
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{(() => {
let typeLabel = "";
if (item.Type === "Episode") {
// For episodes, show season and episode number
const season = item.ParentIndexNumber;
const episode = item.IndexNumber;
if (season && episode) {
typeLabel = `S${season} • E${episode}`;
} else {
typeLabel = "Episode";
}
} else {
typeLabel =
item.Type === "Series"
? "TV Show"
: item.Type === "Movie"
? "Movie"
: item.Type || "";
}
const genres =
item.Genres && item.Genres.length > 0
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
: "";
if (typeLabel && genres) {
return `${typeLabel}${genres}`;
} else if (typeLabel) {
return typeLabel;
} else if (genres) {
return genres;
} else {
return "";
}
})()}
</Animated.Text>
</TouchableOpacity>
</View>
{/* Overview Section - for Episodes and Movies */}
{(item.Type === "Episode" || item.Type === "Movie") &&
item.Overview && (
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
numberOfLines={OVERVIEW_MAX_LINES}
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
fontSize: OVERVIEW_FONT_SIZE,
fontWeight: "400",
textAlign: "center",
maxWidth: OVERVIEW_MAX_WIDTH,
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{item.Overview}
</Animated.Text>
</TouchableOpacity>
</View>
)}
{/* Controls Section */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button */}
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
{selectedOptions && (
<PlayButton
item={item}
selectedOptions={selectedOptions}
colors={currentItemColors}
/>
)}
</View>
{/* Mark as Played */}
<MarkAsPlayedLargeButton
isPlayed={item.UserData?.Played ?? false}
onToggle={togglePlayedStatus}
/>
</View>
</View>
</View>
);
};
// Handle loading state
if (isLoading) {
return (
<View
style={{
height: carouselHeight,
backgroundColor: "#000",
overflow: "hidden",
}}
>
{renderSkeletonLoader()}
</View>
);
}
// Handle empty items
if (!hasItems) {
return null;
}
return (
<View
style={{
height: carouselHeight, // Fixed height instead of flex: 1
backgroundColor: "#000",
overflow: "hidden",
}}
>
<GestureDetector gesture={panGesture}>
<Animated.View
style={[
{
height: carouselHeight, // Fixed height instead of flex: 1
flexDirection: "row",
width: screenWidth * items.length,
},
containerAnimatedStyle,
]}
>
{items.map((item, index) => renderItem(item, index))}
</Animated.View>
</GestureDetector>
{/* Animated Dots Indicator */}
{renderDots()}
</View>
);
};

View File

@@ -1,51 +0,0 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { Platform, View } from "react-native";
import { RoundButton } from "../RoundButton";
interface MarkAsPlayedLargeButtonProps {
isPlayed: boolean;
onToggle: (isPlayed: boolean) => void;
}
export const MarkAsPlayedLargeButton: React.FC<
MarkAsPlayedLargeButtonProps
> = ({ isPlayed, onToggle }) => {
if (Platform.OS === "ios")
return (
<Host
style={{
flex: 0,
width: 50,
height: 50,
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
}}
>
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
<View>
<Ionicons
name='checkmark'
size={24}
color='white'
style={{
marginTop: 6,
marginLeft: 1,
}}
/>
</View>
</Button>
</Host>
);
return (
<View>
<RoundButton
size='large'
icon={isPlayed ? "checkmark" : "checkmark"}
onPress={() => onToggle(isPlayed)}
/>
</View>
);
};

View File

@@ -4,7 +4,7 @@ export function Text(props: TextProps) {
if (Platform.isTV)
return (
<RNText
allowFontScaling={true}
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>

View File

@@ -2,7 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import {
Platform,
TouchableOpacity,
type TouchableOpacityProps,
} from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
@@ -121,6 +125,12 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
}
if (item.Type === "Playlist") {
if (Platform.isTV) {
return {
pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! },
};
}
return {
pathname: "/music/playlist/[playlistId]" as const,
params: { playlistId: item.Id! },

View File

@@ -11,7 +11,7 @@ import heart from "@/assets/icons/heart.fill.png";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { Colors } from "@/constants/Colors";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HORIZONTAL_PADDING = 60;
@@ -28,6 +28,7 @@ type FavoriteTypes =
type EmptyState = Record<FavoriteTypes, boolean>;
export const Favorites = () => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
@@ -148,7 +149,7 @@ export const Favorites = () => {
/>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
@@ -160,7 +161,7 @@ export const Favorites = () => {
style={{
textAlign: "center",
opacity: 0.7,
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
@@ -177,8 +178,6 @@ export const Favorites = () => {
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>

View File

@@ -598,11 +598,14 @@ const HomeMobile = () => {
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{sections.map((section, index) => {
// Render Streamystats sections after Continue Watching and Next Up
// When merged, they appear after index 0; otherwise after index 1
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
? 0
: 1;
// Render Streamystats sections after Recently Added sections
// For default sections: place after Recently Added, before Suggested Movies (if present)
// For custom sections: place at the very end
const hasSuggestedMovies =
!settings?.streamyStatsMovieRecommendations &&
!settings?.home?.sections;
const streamystatsIndex =
sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
const hasStreamystatsContent =
settings.streamyStatsMovieRecommendations ||
settings.streamyStatsSeriesRecommendations ||

View File

@@ -30,19 +30,21 @@ import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
import { TVHeroCarousel } from "@/components/home/TVHeroCarousel";
import { Loader } from "@/components/Loader";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Reduced gap since sections have internal padding for scale animations
const SECTION_GAP = 10;
// Generous gap between sections for Apple TV+ aesthetic
const SECTION_GAP = 24;
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
@@ -51,7 +53,6 @@ type InfiniteScrollingCollectionListSection = {
queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical";
pageSize?: number;
priority?: 1 | 2;
parentId?: string;
};
@@ -61,6 +62,7 @@ type Section = InfiniteScrollingCollectionListSection;
const BACKDROP_DEBOUNCE_MS = 300;
export const Home = () => {
const typography = useScaledTVTypography();
const _router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
@@ -75,7 +77,7 @@ export const Home = () => {
retryCheck,
} = useNetworkStatus();
const _invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showItemActions } = useTVItemActionModal();
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
@@ -201,6 +203,58 @@ export const Home = () => {
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
// Fetch hero items (Continue Watching + Next Up combined)
const { data: heroItems } = useQuery({
queryKey: ["home", "heroItems", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const [resumeResponse, nextUpResponse] = await Promise.all([
getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Overview"],
startIndex: 0,
limit: 10,
}),
getTvShowsApi(api).getNextUp({
userId: user.Id,
startIndex: 0,
limit: 10,
fields: ["Overview"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
}),
]);
const resumeItems = resumeResponse.data.Items || [];
const nextUpItems = nextUpResponse.data.Items || [];
// Combine, sort by recent activity, and dedupe
const combined = [...resumeItems, ...nextUpItems];
const sorted = combined.sort((a, b) => {
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
const seen = new Set<string>();
const deduped: BaseItemDto[] = [];
for (const item of sorted) {
if (!item.Id || seen.has(item.Id)) continue;
seen.add(item.Id);
deduped.push(item);
}
return deduped.slice(0, 15);
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
const userViews = useMemo(
@@ -327,7 +381,6 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
]
: [
@@ -347,7 +400,6 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
{
title: t("home.next_up"),
@@ -365,13 +417,12 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
];
const ss: Section[] = [
...firstSections,
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
...latestMediaViews,
...(!settings?.streamyStatsMovieRecommendations
? [
{
@@ -390,7 +441,6 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList" as const,
orientation: "vertical" as const,
pageSize: 10,
priority: 2 as const,
},
]
: []),
@@ -475,7 +525,6 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical",
pageSize,
priority: index < 2 ? 1 : 2,
});
});
return ss;
@@ -483,23 +532,21 @@ export const Home = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
const highPrioritySectionKeys = useMemo(() => {
return sections
.filter((s) => s.priority === 1)
.map((s) => s.queryKey.join("-"));
}, [sections]);
// Determine if hero should be shown (separate setting from backdrop)
// We need this early to calculate which sections will actually be rendered
const showHero = useMemo(() => {
return heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
}, [heroItems, settings.showTVHeroCarousel]);
const allHighPriorityLoaded = useMemo(() => {
return highPrioritySectionKeys.every((key) => loadedSections.has(key));
}, [highPrioritySectionKeys, loadedSections]);
const markSectionLoaded = useCallback(
(queryKey: (string | undefined | null)[]) => {
const key = queryKey.join("-");
setLoadedSections((prev) => new Set(prev).add(key));
},
[],
);
// Get sections that will actually be rendered (accounting for hero slicing)
// When hero is shown, skip the first sections since hero already displays that content
// - If mergeNextUpAndContinueWatching: skip 1 section (combined Continue & Next Up)
// - Otherwise: skip 2 sections (separate Continue Watching + Next Up)
const renderedSections = useMemo(() => {
if (!showHero) return sections;
const sectionsToSkip = settings.mergeNextUpAndContinueWatching ? 1 : 2;
return sections.slice(sectionsToSkip);
}, [sections, showHero, settings.mergeNextUpAndContinueWatching]);
if (!isConnected || serverConnected !== true) {
let title = "";
@@ -526,7 +573,7 @@ export const Home = () => {
>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
@@ -538,7 +585,7 @@ export const Home = () => {
style={{
textAlign: "center",
opacity: 0.7,
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
@@ -579,7 +626,7 @@ export const Home = () => {
>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
@@ -591,7 +638,7 @@ export const Home = () => {
style={{
textAlign: "center",
opacity: 0.7,
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
@@ -609,82 +656,101 @@ export const Home = () => {
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Dynamic backdrop with crossfade */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.4, 1]}
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
{!showHero && settings.showHomeBackdrop && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
height: "100%",
}}
/>
</View>
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.4, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
}}
/>
</View>
)}
<ScrollView
ref={scrollRef}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingTop: showHero ? 0 : insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => {
// Render Streamystats sections after Continue Watching and Next Up
// When merged, they appear after index 0; otherwise after index 1
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
? 0
: 1;
{/* Hero Carousel - Apple TV+ style featured content */}
{showHero && heroItems && (
<TVHeroCarousel
items={heroItems}
onItemFocus={handleItemFocus}
onItemLongPress={showItemActions}
/>
)}
<View
style={{
gap: SECTION_GAP,
paddingTop: showHero ? SECTION_GAP : 0,
}}
>
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
{renderedSections.map((section, index) => {
// Render Streamystats sections after Recently Added sections
// For default sections: place after Recently Added, before Suggested Movies (if present)
// For custom sections: place at the very end
const hasSuggestedMovies =
!settings?.streamyStatsMovieRecommendations &&
!settings?.home?.sections;
const displayedSectionsLength = renderedSections.length;
const streamystatsIndex =
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
const hasStreamystatsContent =
settings.streamyStatsMovieRecommendations ||
settings.streamyStatsSeriesRecommendations ||
@@ -698,7 +764,6 @@ export const Home = () => {
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
@@ -708,13 +773,11 @@ export const Home = () => {
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
@@ -722,8 +785,8 @@ export const Home = () => {
) : null;
if (section.type === "InfiniteScrollingCollectionList") {
const isHighPriority = section.priority === 1;
const isFirstSection = index === 0;
// First section only gets preferred focus if hero is not shown
const isFirstSection = index === 0 && !showHero;
return (
<View key={index} style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
@@ -733,12 +796,6 @@ export const Home = () => {
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
enabled={isHighPriority || allHighPriorityLoaded}
onLoaded={
isHighPriority
? () => markSectionLoaded(section.queryKey)
: undefined
}
isFirstSection={isFirstSection}
onItemFocus={handleItemFocus}
parentId={section.parentId}

View File

@@ -1,631 +0,0 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
View,
} from "react-native";
import Animated, {
useAnimatedRef,
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical";
pageSize?: number;
};
type MediaListSectionType = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
export const HomeWithCarousel = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const headerOverlayOffset = Platform.isTV ? 0 : 60;
const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(animatedScrollRef);
const { downloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const {
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
if (isConnected && !prevIsConnected.current) {
invalidateCache();
}
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
}, [downloadedItems]);
useEffect(() => {
if (Platform.isTV) {
navigation.setOptions({
headerLeft: () => null,
});
return;
}
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
),
});
}, [navigation, router, hasDownloads]);
useEffect(() => {
cleanCacheDirectory().catch((_e) =>
console.error("Something went wrong cleaning cache directory"),
);
}, []);
const segments = useSegments();
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
animatedScrollRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
});
return () => {
unsubscribe();
};
}, [segments]);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries],
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType),
) || []
);
}, [userViews]);
const _refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined,
pageSize: number = 10,
): InfiniteScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async ({ pageParam = 0 }) => {
if (!api) return [];
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 100, // Fetch a larger set for pagination
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes,
parentId,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
},
type: "InfiniteScrollingCollectionList",
pageSize,
}),
[api, user?.Id],
);
const defaultSections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey: string[] = [
"home",
`recentlyAddedIn${c.CollectionType}`,
user.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id,
10,
);
});
// Helper to sort items by most recent activity
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
return items.sort((a, b) => {
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
};
// Helper to deduplicate items by ID
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
const seen = new Set<string>();
return items.filter((item) => {
if (!item.Id || seen.has(item.Id)) return false;
seen.add(item.Id);
return true;
});
};
// Build the first sections based on merge setting
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
? [
{
title: t("home.continue_and_next_up"),
queryKey: ["home", "continueAndNextUp"],
queryFn: async ({ pageParam = 0 }) => {
// Fetch both in parallel
const [resumeResponse, nextUpResponse] = await Promise.all([
getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
startIndex: 0,
limit: 20,
}),
getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: 0,
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
}),
]);
const resumeItems = resumeResponse.data.Items || [];
const nextUpItems = nextUpResponse.data.Items || [];
// Combine, sort by recent activity, deduplicate
const combined = [...resumeItems, ...nextUpItems];
const sorted = sortByRecentActivity(combined);
const deduplicated = deduplicateById(sorted);
// Paginate client-side
return deduplicated.slice(pageParam, pageParam + 10);
},
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
},
]
: [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async ({ pageParam = 0 }) =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
startIndex: pageParam,
limit: 10,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async ({ pageParam = 0 }) =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: 10,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
},
];
const ss: Section[] = [
...firstSections,
...latestMediaViews,
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
...(!settings?.streamyStatsMovieRecommendations
? [
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
startIndex: pageParam,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "InfiniteScrollingCollectionList" as const,
orientation: "vertical" as const,
pageSize: 10,
},
]
: []),
];
return ss;
}, [
api,
user?.Id,
collections,
t,
createCollectionConfig,
settings?.streamyStatsMovieRecommendations,
settings.mergeNextUpAndContinueWatching,
]);
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
settings.home.sections.forEach((section, index) => {
const id = section.title || `section-${index}`;
const pageSize = 10;
ss.push({
title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async ({ pageParam = 0 }) => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
startIndex: pageParam,
limit: section.items?.limit || pageSize,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
}
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
return response.data.Items || [];
}
if (section.latest) {
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 100, // Fetch larger set
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
}
if (section.custom) {
const response = await api.get<BaseItemDtoQueryResult>(
section.custom.endpoint,
{
params: {
...(section.custom.query || {}),
userId: user?.Id,
startIndex: pageParam,
limit: pageSize,
},
headers: section.custom.headers || {},
},
);
return response.data.Items || [];
}
return [];
},
type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical",
pageSize,
});
});
return ss;
}, [api, user?.Id, settings?.home?.sections, t]);
const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
if (!isConnected) {
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
return (
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
<Text className='text-3xl font-bold mb-2'>{title}</Text>
<Text className='text-center opacity-70'>{subtitle}</Text>
<View className='mt-4'>
{!Platform.isTV && (
<Button
color='purple'
onPress={() => router.push("/(auth)/downloads")}
justify='center'
iconRight={
<Ionicons name='arrow-forward' size={20} color='white' />
}
>
{t("home.go_to_downloads")}
</Button>
)}
<Button
color='black'
onPress={retryCheck}
justify='center'
className='mt-2'
iconRight={
retryLoading ? null : (
<Ionicons name='refresh' size={20} color='white' />
)
}
>
{retryLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("home.retry")
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className='flex flex-col items-center justify-center h-full -mt-6'>
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
<Text className='text-center opacity-70'>
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
return (
<Animated.ScrollView
scrollToOverflowEnabled={true}
ref={animatedScrollRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='never'
scrollEventThrottle={16}
bounces={false}
overScrollMode='never'
style={{ marginTop: -headerOverlayOffset }}
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
onScroll={(event) => {
setScrollY(event.nativeEvent.contentOffset.y);
}}
>
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
<View
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
paddingTop: 0,
}}
>
<View className='flex flex-col space-y-4'>
{sections.map((section, index) => {
// Render Streamystats sections after Continue Watching and Next Up
// When merged, they appear after index 0; otherwise after index 1
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
? 0
: 1;
const hasStreamystatsContent =
settings.streamyStatsMovieRecommendations ||
settings.streamyStatsSeriesRecommendations ||
settings.streamyStatsPromotedWatchlists;
const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent ? (
<>
{settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
/>
)}
{settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists />
)}
</>
) : null;
if (section.type === "InfiniteScrollingCollectionList") {
return (
<View key={index} className='flex flex-col space-y-4'>
<InfiniteScrollingCollectionList
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
/>
{streamystatsSections}
</View>
);
}
if (section.type === "MediaListSection") {
return (
<View key={index} className='flex flex-col space-y-4'>
<MediaListSection
queryKey={section.queryKey}
queryFn={section.queryFn}
scrollY={scrollY}
enableLazyLoading={true}
/>
{streamystatsSections}
</View>
);
}
return null;
})}
</View>
</View>
<View className='h-24' />
</Animated.ScrollView>
);
};

View File

@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
},
initialPageParam: 0,
staleTime: 60 * 1000, // 1 minute
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,

View File

@@ -6,7 +6,7 @@ import {
useInfiniteQuery,
} from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -16,19 +16,15 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import ContinueWatchingPoster, {
TV_LANDSCAPE_WIDTH,
} from "../ContinueWatchingPoster.tv";
import SeriesPoster from "../posters/SeriesPoster.tv";
const ITEM_GAP = 16;
// Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = 20;
@@ -42,59 +38,13 @@ interface Props extends ViewProps {
pageSize?: number;
onPressSeeAll?: () => void;
enabled?: boolean;
onLoaded?: () => void;
isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
parentId?: string;
}
// TV-specific ItemCardText with larger fonts
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
</>
) : (
<>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</>
)}
</View>
);
};
type Typography = ReturnType<typeof useScaledTVTypography>;
type PosterSizes = ReturnType<typeof useScaledTVPosterSizes>;
// TV-specific "See All" card for end of lists
const TVSeeAllCard: React.FC<{
@@ -103,10 +53,20 @@ const TVSeeAllCard: React.FC<{
disabled?: boolean;
onFocus?: () => void;
onBlur?: () => void;
}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => {
typography: Typography;
posterSizes: PosterSizes;
}> = ({
onPress,
orientation,
disabled,
onFocus,
onBlur,
typography,
posterSizes,
}) => {
const { t } = useTranslation();
const width =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
return (
@@ -137,7 +97,7 @@ const TVSeeAllCard: React.FC<{
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#FFFFFF",
fontWeight: "600",
}}
@@ -159,70 +119,49 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
hideIfEmpty = false,
pageSize = 10,
enabled = true,
onLoaded,
isFirstSection = false,
onItemFocus,
parentId,
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const effectivePageSize = Math.max(1, pageSize);
const hasCalledOnLoaded = useRef(false);
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
// Track focus within section for item focus/blur callbacks
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [_focusedCount, setFocusedCount] = useState(0);
// Pass through focus callbacks without tracking internal state
const handleItemFocus = useCallback(
(item: BaseItemDto) => {
setFocusedCount((c) => c + 1);
onItemFocus?.(item);
},
[onItemFocus],
);
const handleItemBlur = useCallback(() => {
setFocusedCount((c) => Math.max(0, c - 1));
}, []);
// Focus handler for See All card (doesn't need item parameter)
const handleSeeAllFocus = useCallback(() => {
setFocusedCount((c) => c + 1);
}, []);
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isSuccess,
} = useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
useEffect(() => {
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
hasCalledOnLoaded.current = true;
onLoaded();
}
}, [isSuccess, onLoaded]);
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
const { t } = useTranslation();
@@ -243,7 +182,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}, [data]);
const itemWidth =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
@@ -271,79 +210,21 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
} as any);
}, [router, parentId]);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: itemWidth + ITEM_GAP,
offset: (itemWidth + ITEM_GAP) * index,
index,
}),
[itemWidth],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
const isHorizontal = orientation === "horizontal";
const renderPoster = () => {
if (item.Type === "Episode" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Episode" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Movie" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Movie" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Series" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Series" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Program") {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "BoxSet" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "BoxSet" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Playlist" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Playlist" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Video" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Video" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// Default fallback
return isHorizontal ? (
<ContinueWatchingPoster item={item} />
) : (
<MoviePoster item={item} />
);
};
return (
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
<TVFocusablePoster
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation={orientation}
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
onBlur={handleItemBlur}
>
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText item={item} />
width={itemWidth}
/>
</View>
);
},
@@ -352,8 +233,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
isFirstSection,
itemWidth,
handleItemPress,
showItemActions,
handleItemFocus,
handleItemBlur,
ITEM_GAP,
],
);
@@ -365,11 +247,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
{/* Section Header */}
<Text
style={{
fontSize: TVTypography.body,
fontWeight: "600",
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: 20,
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
{title}
@@ -379,8 +262,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
<Text
style={{
color: "#737373",
fontSize: TVTypography.callout,
marginLeft: SCALE_PADDING,
fontSize: typography.callout,
marginLeft: sizes.padding.horizontal,
}}
>
{t("home.no_items")}
@@ -420,7 +303,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: TVTypography.callout,
fontSize: typography.callout,
}}
numberOfLines={1}
>
@@ -444,12 +327,15 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
ListFooterComponent={
<View
@@ -475,8 +361,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onPress={handleSeeAllPress}
orientation={orientation}
disabled={disabled}
onFocus={handleSeeAllFocus}
onBlur={handleItemBlur}
typography={typography}
posterSizes={posterSizes}
/>
)}
</View>

View File

@@ -44,7 +44,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryKey: queryKey,
queryFn,
staleTime: 60 * 1000, // 1 minute
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled: enableLazyLoading ? isInView : true,

View File

@@ -80,7 +80,6 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
Boolean(api?.accessToken) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
@@ -215,7 +214,6 @@ export const StreamystatsPromotedWatchlists: React.FC<
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});

View File

@@ -11,43 +11,19 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist;
jellyfinServerId: string;
@@ -60,10 +36,15 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
onItemFocus,
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -108,8 +89,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
Boolean(settings?.streamyStatsServerUrl) &&
Boolean(api?.accessToken) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
@@ -123,30 +104,35 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
length: posterSizes.poster + ITEM_GAP,
offset: (posterSizes.poster + ITEM_GAP) * index,
index,
}),
[],
[posterSizes.poster, ITEM_GAP],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
width={posterSizes.poster}
/>
</View>
);
},
[handleItemPress, onItemFocus],
[
ITEM_GAP,
posterSizes.poster,
handleItemPress,
showItemActions,
onItemFocus,
],
);
if (!isLoading && (!items || items.length === 0)) return null;
@@ -155,11 +141,12 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: TVTypography.body,
fontWeight: "600",
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: 20,
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
{watchlist.name}
@@ -175,11 +162,11 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View key={i} style={{ width: posterSizes.poster }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
@@ -201,9 +188,13 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
)}
@@ -219,6 +210,9 @@ interface StreamystatsPromotedWatchlistsProps extends ViewProps {
export const StreamystatsPromotedWatchlists: React.FC<
StreamystatsPromotedWatchlistsProps
> = ({ enabled = true, onItemFocus, ...props }) => {
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
@@ -278,8 +272,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
@@ -309,11 +303,11 @@ export const StreamystatsPromotedWatchlists: React.FC<
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View key={i} style={{ width: posterSizes.poster }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,

View File

@@ -103,7 +103,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnMount: false,
refetchOnWindowFocus: false,
});
@@ -136,7 +135,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
enabled:
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});

View File

@@ -11,21 +11,16 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
interface Props extends ViewProps {
title: string;
type: "Movie" | "Series";
@@ -34,28 +29,6 @@ interface Props extends ViewProps {
onItemFocus?: (item: BaseItemDto) => void;
}
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
export const StreamystatsRecommendations: React.FC<Props> = ({
title,
type,
@@ -64,10 +37,13 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
onItemFocus,
...props
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -133,8 +109,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
@@ -166,8 +142,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
},
enabled:
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
@@ -184,30 +160,29 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
length: sizes.posters.poster + sizes.gaps.item,
offset: (sizes.posters.poster + sizes.gaps.item) * index,
index,
}),
[],
[sizes],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
<View style={{ marginRight: sizes.gaps.item }}>
<TVPosterCard
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
width={sizes.posters.poster}
/>
</View>
);
},
[handleItemPress, onItemFocus],
[sizes, handleItemPress, showItemActions, onItemFocus],
);
if (!streamyStatsEnabled) return null;
@@ -218,11 +193,12 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: TVTypography.body,
fontWeight: "600",
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: 20,
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
{title}
@@ -232,17 +208,17 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View key={i} style={{ width: sizes.posters.poster }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
width: sizes.posters.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
@@ -264,9 +240,13 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale,
}}
/>
)}

View File

@@ -0,0 +1,651 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Animated,
Dimensions,
Easing,
FlatList,
Platform,
Pressable,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
interface TVHeroCarouselProps {
items: BaseItemDto[];
onItemFocus?: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
}
interface HeroCardProps {
item: BaseItemDto;
isFirst: boolean;
sizes: ScaledTVSizes;
onFocus: (item: BaseItemDto) => void;
onPress: (item: BaseItemDto) => void;
onLongPress?: (item: BaseItemDto) => void;
}
const HeroCard: React.FC<HeroCardProps> = React.memo(
({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => {
const api = useAtomValue(apiAtom);
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
// Check if glass effect is available (tvOS 26+)
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
const posterUrl = useMemo(() => {
if (!api) return null;
// For episodes, always use series thumb
if (item.Type === "Episode") {
if (item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
}
if (item.SeriesId) {
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
}
}
// For non-episodes, use item's own thumb/primary
if (item.ImageTags?.Thumb) {
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
}
if (item.ImageTags?.Primary) {
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
}
return null;
}, [api, item]);
const animateTo = useCallback(
(value: number) =>
Animated.timing(scale, {
toValue: value,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start(),
[scale],
);
const handleFocus = useCallback(() => {
setFocused(true);
animateTo(sizes.animation.focusScale);
onFocus(item);
}, [animateTo, onFocus, item, sizes.animation.focusScale]);
const handleBlur = useCallback(() => {
setFocused(false);
animateTo(1);
}, [animateTo]);
const handlePress = useCallback(() => {
onPress(item);
}, [onPress, item]);
const handleLongPress = useCallback(() => {
onLongPress?.(item);
}, [onLongPress, item]);
// Use glass poster for tvOS 26+
if (useGlass && posterUrl) {
const progress = item.UserData?.PlayedPercentage || 0;
return (
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
style={{ marginRight: sizes.gaps.item }}
>
<GlassPosterView
imageUrl={posterUrl}
aspectRatio={16 / 9}
cornerRadius={24}
progress={progress}
showWatchedIndicator={false}
isFocused={focused}
width={sizes.posters.episode}
style={{ width: sizes.posters.episode }}
/>
</Pressable>
);
}
// Fallback for non-tvOS or older tvOS
return (
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
style={{ marginRight: sizes.gaps.item }}
>
<Animated.View
style={{
width: sizes.posters.episode,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
transform: [{ scale }],
borderWidth: 2,
borderColor: focused ? "#FFFFFF" : "transparent",
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "rgba(255,255,255,0.1)",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='film-outline'
size={48}
color='rgba(255,255,255,0.3)'
/>
</View>
)}
<ProgressBar item={item} />
</Animated.View>
</Pressable>
);
},
);
// Debounce delay to prevent rapid backdrop changes when navigating fast
const BACKDROP_DEBOUNCE_MS = 300;
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
items,
onItemFocus,
onItemLongPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom);
const _insets = useSafeAreaInsets();
const router = useRouter();
// Active item for featured display (debounced)
const [activeItem, setActiveItem] = useState<BaseItemDto | null>(
items[0] || null,
);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Crossfade animation state
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
const [layer0Url, setLayer0Url] = useState<string | null>(null);
const [layer1Url, setLayer1Url] = useState<string | null>(null);
const layer0Opacity = useRef(new Animated.Value(0)).current;
const layer1Opacity = useRef(new Animated.Value(0)).current;
// Get backdrop URL for active item
const backdropUrl = useMemo(() => {
if (!activeItem) return null;
return getBackdropUrl({
api,
item: activeItem,
quality: 90,
width: 1920,
});
}, [api, activeItem]);
// Get logo URL for active item
const logoUrl = useMemo(() => {
if (!activeItem) return null;
return getLogoImageUrlById({ api, item: activeItem });
}, [api, activeItem]);
// Crossfade effect for backdrop
useEffect(() => {
if (!backdropUrl) return;
let isCancelled = false;
const performCrossfade = async () => {
try {
await Image.prefetch(backdropUrl);
} catch {
// Continue even if prefetch fails
}
if (isCancelled) return;
const incomingLayer = activeLayer === 0 ? 1 : 0;
const incomingOpacity =
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
const outgoingOpacity =
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
if (incomingLayer === 0) {
setLayer0Url(backdropUrl);
} else {
setLayer1Url(backdropUrl);
}
await new Promise((resolve) => setTimeout(resolve, 50));
if (isCancelled) return;
Animated.parallel([
Animated.timing(incomingOpacity, {
toValue: 1,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(outgoingOpacity, {
toValue: 0,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]).start(() => {
if (!isCancelled) {
setActiveLayer(incomingLayer);
}
});
};
performCrossfade();
return () => {
isCancelled = true;
};
}, [backdropUrl]);
// Handle card focus with debounce
const handleCardFocus = useCallback(
(item: BaseItemDto) => {
// Clear any pending debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new timer to update active item after debounce delay
debounceTimerRef.current = setTimeout(() => {
setActiveItem(item);
onItemFocus?.(item);
}, BACKDROP_DEBOUNCE_MS);
},
[onItemFocus],
);
// Handle card press - navigate to item
const handleCardPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)");
router.push(navigation as any);
},
[router],
);
// Get metadata for active item
const year = activeItem?.ProductionYear;
const duration = activeItem?.RunTimeTicks
? runtimeTicksToMinutes(activeItem.RunTimeTicks)
: null;
const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0;
const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0;
// Get display title
const displayTitle = useMemo(() => {
if (!activeItem) return "";
if (activeItem.Type === "Episode") {
return activeItem.SeriesName || activeItem.Name || "";
}
return activeItem.Name || "";
}, [activeItem]);
// Get subtitle for episodes
const episodeSubtitle = useMemo(() => {
if (!activeItem || activeItem.Type !== "Episode") return null;
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
}, [activeItem]);
// Memoize hero items to prevent re-renders
const heroItems = useMemo(() => items.slice(0, 8), [items]);
// Memoize renderItem for FlatList
const renderHeroCard = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<HeroCard
item={item}
isFirst={index === 0}
sizes={sizes}
onFocus={handleCardFocus}
onPress={handleCardPress}
onLongPress={onItemLongPress}
/>
),
[handleCardFocus, handleCardPress, onItemLongPress, sizes],
);
// Memoize keyExtractor
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []);
if (items.length === 0) return null;
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (
<View style={{ height: heroHeight, width: "100%" }}>
{/* Backdrop layers with crossfade */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.5)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
<LinearGradient
colors={["rgba(0,0,0,0.4)", "transparent"]}
locations={[0, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: "40%",
}}
/>
{/* Horizontal gradient for left side text contrast */}
<LinearGradient
colors={["rgba(0,0,0,0.9)", "rgba(0,0,0,0.6)", "transparent"]}
locations={[0, 0.5, 0.85]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "70%",
}}
/>
</View>
{/* Content overlay - text elements with padding */}
<View
style={{
position: "absolute",
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
bottom:
40 + sizes.posters.episode * (9 / 16) + sizes.gaps.small * 2 + 20,
}}
>
{/* Logo or Title */}
{logoUrl ? (
<Image
source={{ uri: logoUrl }}
style={{
height: 100,
width: SCREEN_WIDTH * 0.35,
marginBottom: 16,
}}
contentFit='contain'
contentPosition='left'
/>
) : (
<Text
style={{
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 12,
}}
numberOfLines={1}
>
{displayTitle}
</Text>
)}
{/* Episode subtitle */}
{episodeSubtitle && (
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.9)",
marginBottom: 12,
}}
numberOfLines={1}
>
{episodeSubtitle}
</Text>
)}
{/* Description */}
{activeItem?.Overview && (
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.8)",
marginBottom: 16,
maxWidth: SCREEN_WIDTH * 0.5,
lineHeight: typography.body * 1.4,
}}
numberOfLines={2}
>
{activeItem.Overview}
</Text>
)}
{/* Metadata badges */}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 16,
}}
>
{year && (
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.8)",
}}
>
{year}
</Text>
)}
{duration && (
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.8)",
}}
>
{duration}
</Text>
)}
{activeItem?.OfficialRating && (
<View
style={{
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.5)",
}}
>
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.8)",
}}
>
{activeItem.OfficialRating}
</Text>
</View>
)}
{hasProgress && (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
}}
>
<View
style={{
width: 60,
height: 4,
backgroundColor: "rgba(255,255,255,0.3)",
borderRadius: 2,
overflow: "hidden",
}}
>
<View
style={{
width: `${playedPercent}%`,
height: "100%",
backgroundColor: "#FFFFFF",
borderRadius: 2,
}}
/>
</View>
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.8)",
}}
>
{Math.round(playedPercent)}%
</Text>
</View>
)}
</View>
</View>
{/* Thumbnail carousel - edge-to-edge */}
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 40,
}}
>
<FlatList
horizontal
data={heroItems}
keyExtractor={keyExtractor}
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
renderItem={renderHeroCard}
removeClippedSubviews={false}
initialNumToRender={8}
maxToRenderPerBatch={8}
windowSize={3}
/>
</View>
</View>
);
};

View File

@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
type DiscoverEndpoint,
@@ -33,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item,
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -130,7 +131,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
</View>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#fff",
fontWeight: "600",
marginTop: 12,
@@ -142,7 +143,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
{year && (
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
@@ -164,6 +165,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
slide,
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -232,7 +234,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,

View File

@@ -22,7 +22,7 @@ import { Loader } from "@/components/Loader";
import { JellyserrRatings } from "@/components/Ratings";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
@@ -68,6 +68,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
onPress,
refSetter,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
@@ -128,7 +129,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
</View>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600",
textAlign: "center",
@@ -158,6 +159,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
};
export const TVJellyseerrPage: React.FC = () => {
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
@@ -552,7 +554,7 @@ export const TVJellyseerrPage: React.FC = () => {
{/* Title */}
<Text
style={{
fontSize: TVTypography.display,
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginTop: 8,
@@ -566,7 +568,7 @@ export const TVJellyseerrPage: React.FC = () => {
{/* Year */}
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "rgba(255,255,255,0.7)",
marginBottom: 16,
}}
@@ -601,7 +603,7 @@ export const TVJellyseerrPage: React.FC = () => {
>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#E5E7EB",
lineHeight: 32,
}}
@@ -636,7 +638,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "bold",
color: "#000000",
}}
@@ -663,7 +665,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "bold",
color: "#FFFFFF",
}}
@@ -698,7 +700,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -732,7 +734,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -757,7 +759,7 @@ export const TVJellyseerrPage: React.FC = () => {
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginLeft: 8,
}}
@@ -776,7 +778,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -794,7 +796,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -813,7 +815,7 @@ export const TVJellyseerrPage: React.FC = () => {
<View style={{ marginTop: 24 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,

View File

@@ -20,7 +20,7 @@ import {
import { Text } from "@/components/common/Text";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type {
QualityProfile,
@@ -51,6 +51,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
onClose,
onRequested,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
@@ -389,7 +390,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
@@ -399,7 +400,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
</Text>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
}}
@@ -473,7 +474,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "bold",
color: "#FFFFFF",
}}

View File

@@ -3,7 +3,7 @@ import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVRequestOptionRowProps {
label: string;
@@ -20,6 +20,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
hasTVPreferredFocus = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.02,
@@ -56,7 +57,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
}}
>
@@ -65,7 +66,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
fontWeight: "500",
}}

View File

@@ -2,7 +2,7 @@ import React from "react";
import { Animated, Pressable, ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface ToggleItem {
id: number;
@@ -21,6 +21,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
onToggle,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.08,
@@ -57,7 +58,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused ? "#000" : "#fff",
fontWeight: item.selected || focused ? "600" : "400",
}}
@@ -82,13 +83,14 @@ export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
onToggle,
disabled = false,
}) => {
const typography = useScaledTVTypography();
if (items.length === 0) return null;
return (
<View style={{ marginBottom: 16 }}>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 10,
}}

View File

@@ -15,6 +15,7 @@ import { Animated, Easing, FlatList, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -55,6 +56,7 @@ const TVLibraryRow: React.FC<{
}> = ({ library, isFirst, onPress }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
const typography = useScaledTVTypography();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const opacity = useRef(new Animated.Value(0.7)).current;
@@ -101,6 +103,8 @@ const TVLibraryRow: React.FC<{
return t("library.item_types.series");
if (library.CollectionType === "boxsets")
return t("library.item_types.boxsets");
if (library.CollectionType === "playlists")
return t("library.item_types.playlists");
if (library.CollectionType === "music")
return t("library.item_types.items");
return t("library.item_types.items");
@@ -190,7 +194,7 @@ const TVLibraryRow: React.FC<{
<Text
numberOfLines={1}
style={{
fontSize: 32,
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
textShadowColor: "rgba(0,0,0,0.8)",
@@ -203,7 +207,7 @@ const TVLibraryRow: React.FC<{
{library.itemCount !== undefined && (
<Text
style={{
fontSize: 18,
fontSize: typography.body,
color: "rgba(255,255,255,0.7)",
marginTop: 4,
textShadowColor: "rgba(0,0,0,0.8)",
@@ -237,6 +241,7 @@ export const TVLibraries: React.FC = () => {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { data: userViews, isLoading: viewsLoading } = useQuery({
queryKey: ["user-views", user?.Id],
@@ -255,8 +260,7 @@ export const TVLibraries: React.FC = () => {
userViews
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books")
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "playlists") || [],
.filter((l) => l.CollectionType !== "music") || [],
[userViews, settings?.hiddenLibraries],
);
@@ -270,6 +274,10 @@ export const TVLibraries: React.FC = () => {
if (library.CollectionType === "movies") itemType = "Movie";
else if (library.CollectionType === "tvshows") itemType = "Series";
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
else if (library.CollectionType === "playlists")
itemType = "Playlist";
const isPlaylistsLib = library.CollectionType === "playlists";
// Fetch count
const countResponse = await getItemsApi(api!).getItems({
@@ -278,6 +286,7 @@ export const TVLibraries: React.FC = () => {
recursive: true,
limit: 0,
includeItemTypes: itemType ? [itemType as any] : undefined,
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
});
// Fetch preview items with backdrops
@@ -289,6 +298,7 @@ export const TVLibraries: React.FC = () => {
sortBy: ["Random"],
includeItemTypes: itemType ? [itemType as any] : undefined,
imageTypes: ["Backdrop"],
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
});
return {
@@ -306,6 +316,10 @@ export const TVLibraries: React.FC = () => {
const handleLibraryPress = useCallback(
(library: BaseItemDto) => {
if (library.CollectionType === "livetv") {
router.push("/(auth)/(tabs)/(libraries)/livetv/programs");
return;
}
if (library.CollectionType === "music") {
router.push({
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
@@ -360,7 +374,7 @@ export const TVLibraries: React.FC = () => {
alignItems: "center",
}}
>
<Text style={{ fontSize: 20, color: "#737373" }}>
<Text style={{ fontSize: typography.body, color: "#737373" }}>
{t("library.no_libraries_found")}
</Text>
</View>

View File

@@ -12,6 +12,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -44,6 +45,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const typography = useScaledTVTypography();
const url = useMemo(
() =>
@@ -148,7 +150,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
<Text
numberOfLines={1}
style={{
fontSize: 22,
fontSize: typography.body,
fontWeight: "600",
color: "#FFFFFF",
marginTop: 12,
@@ -160,7 +162,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
{itemsCount !== undefined && (
<Text
style={{
fontSize: 14,
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
}}

View File

@@ -0,0 +1,183 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { Animated, Image, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface TVChannelCardProps {
channel: BaseItemDto;
api: Api | null;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
const CARD_WIDTH = 200;
const CARD_HEIGHT = 160;
export const TVChannelCard: React.FC<TVChannelCardProps> = ({
channel,
api,
onPress,
hasTVPreferredFocus = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const imageUrl = getPrimaryImageUrl({
api,
item: channel,
quality: 80,
width: 200,
});
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
focusable={!disabled}
style={styles.pressable}
>
<Animated.View
style={[
styles.container,
{
backgroundColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.08)",
borderColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
},
animatedStyle,
focused && styles.focusedShadow,
]}
>
{/* Channel logo or number */}
<View style={styles.logoContainer}>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={styles.logo}
resizeMode='contain'
/>
) : (
<View
style={[
styles.numberFallback,
{
backgroundColor: focused
? "#E5E5E5"
: "rgba(255, 255, 255, 0.15)",
},
]}
>
<Text
style={[
styles.numberText,
{
fontSize: typography.title,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{channel.ChannelNumber || "?"}
</Text>
</View>
)}
</View>
{/* Channel name */}
<Text
numberOfLines={2}
style={[
styles.channelName,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{channel.Name}
</Text>
{/* Channel number (if name is shown) */}
{channel.ChannelNumber && (
<Text
numberOfLines={1}
style={[
styles.channelNumber,
{
fontSize: typography.callout,
color: focused
? "rgba(0, 0, 0, 0.6)"
: "rgba(255, 255, 255, 0.5)",
},
]}
>
Ch. {channel.ChannelNumber}
</Text>
)}
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
pressable: {
width: CARD_WIDTH,
height: CARD_HEIGHT,
},
container: {
flex: 1,
borderRadius: 12,
borderWidth: 1,
padding: 12,
alignItems: "center",
justifyContent: "center",
},
focusedShadow: {
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 12,
},
logoContainer: {
width: 80,
height: 60,
marginBottom: 8,
justifyContent: "center",
alignItems: "center",
},
logo: {
width: "100%",
height: "100%",
},
numberFallback: {
width: 60,
height: 60,
borderRadius: 30,
justifyContent: "center",
alignItems: "center",
},
numberText: {
fontWeight: "bold",
},
channelName: {
fontWeight: "600",
textAlign: "center",
marginBottom: 4,
},
channelNumber: {
fontWeight: "400",
},
});
export { CARD_WIDTH, CARD_HEIGHT };

View File

@@ -0,0 +1,136 @@
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { TVChannelCard } from "./TVChannelCard";
const HORIZONTAL_PADDING = 60;
const GRID_GAP = 16;
export const TVChannelsGrid: React.FC = () => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const router = useRouter();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Fetch all channels
const { data: channelsData, isLoading } = useQuery({
queryKey: ["livetv", "channels-grid", "all"],
queryFn: async () => {
if (!api || !user?.Id) return null;
const res = await getLiveTvApi(api).getLiveTvChannels({
enableFavoriteSorting: true,
userId: user.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
enabled: !!api && !!user?.Id,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const channels = channelsData?.Items ?? [];
const handleChannelPress = useCallback(
(channelId: string | undefined) => {
if (channelId) {
// Navigate directly to the player to start the channel
const queryParams = new URLSearchParams({
itemId: channelId,
audioIndex: "",
subtitleIndex: "",
mediaSourceId: "",
bitrateValue: "",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
}
},
[router],
);
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#FFFFFF' />
</View>
);
}
if (channels.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { fontSize: typography.body }]}>
{t("live_tv.no_channels")}
</Text>
</View>
);
}
return (
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
{
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
paddingBottom: insets.bottom + 60,
},
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.grid}>
{channels.map((channel, index) => (
<TVChannelCard
key={channel.Id ?? index}
channel={channel}
api={api}
onPress={() => handleChannelPress(channel.Id)}
// No hasTVPreferredFocus - tab buttons handle initial focus
/>
))}
</View>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
paddingTop: 24,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "flex-start",
gap: GRID_GAP,
overflow: "visible",
paddingVertical: 10, // Extra padding for focus scale animation
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyText: {
color: "rgba(255, 255, 255, 0.6)",
},
});

View File

@@ -0,0 +1,146 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React, { useMemo } from "react";
import { StyleSheet, View } from "react-native";
import { TVGuideProgramCell } from "./TVGuideProgramCell";
interface TVGuideChannelRowProps {
programs: BaseItemDto[];
baseTime: Date;
pixelsPerHour: number;
minProgramWidth: number;
hoursToShow: number;
onProgramPress: (program: BaseItemDto) => void;
disabled?: boolean;
firstProgramRefSetter?: (ref: View | null) => void;
}
export const TVGuideChannelRow: React.FC<TVGuideChannelRowProps> = ({
programs,
baseTime,
pixelsPerHour,
minProgramWidth,
hoursToShow,
onProgramPress,
disabled = false,
firstProgramRefSetter,
}) => {
const isCurrentlyAiring = (program: BaseItemDto): boolean => {
if (!program.StartDate || !program.EndDate) return false;
const now = new Date();
const start = new Date(program.StartDate);
const end = new Date(program.EndDate);
return now >= start && now <= end;
};
const getTimeOffset = (startDate: string): number => {
const start = new Date(startDate);
const diffMinutes = (start.getTime() - baseTime.getTime()) / 60000;
return Math.max(0, (diffMinutes / 60) * pixelsPerHour);
};
// Filter programs for this channel and within the time window
const filteredPrograms = useMemo(() => {
const endTime = new Date(baseTime.getTime() + hoursToShow * 60 * 60 * 1000);
return programs
.filter((p) => {
if (!p.StartDate || !p.EndDate) return false;
const start = new Date(p.StartDate);
const end = new Date(p.EndDate);
// Program overlaps with our time window
return end > baseTime && start < endTime;
})
.sort((a, b) => {
const dateA = new Date(a.StartDate || 0);
const dateB = new Date(b.StartDate || 0);
return dateA.getTime() - dateB.getTime();
});
}, [programs, baseTime, hoursToShow]);
// Calculate program cells with positions (absolute positioning)
const programCells = useMemo(() => {
return filteredPrograms.map((program) => {
if (!program.StartDate || !program.EndDate) {
return { program, width: minProgramWidth, left: 0 };
}
// Clamp the start time to baseTime if program started earlier
const programStart = new Date(program.StartDate);
const effectiveStart = programStart < baseTime ? baseTime : programStart;
// Clamp the end time to the window end
const windowEnd = new Date(
baseTime.getTime() + hoursToShow * 60 * 60 * 1000,
);
const programEnd = new Date(program.EndDate);
const effectiveEnd = programEnd > windowEnd ? windowEnd : programEnd;
const durationMinutes =
(effectiveEnd.getTime() - effectiveStart.getTime()) / 60000;
const width = Math.max(
(durationMinutes / 60) * pixelsPerHour - 4,
minProgramWidth,
); // -4 for gap
const left = getTimeOffset(effectiveStart.toISOString());
return {
program,
width,
left,
};
});
}, [filteredPrograms, baseTime, pixelsPerHour, minProgramWidth, hoursToShow]);
const totalWidth = hoursToShow * pixelsPerHour;
return (
<View style={[styles.container, { width: totalWidth }]}>
{programCells.map(({ program, width, left }, index) => (
<View
key={program.Id || index}
style={[styles.programCellWrapper, { left, width }]}
>
<TVGuideProgramCell
program={program}
width={width}
isCurrentlyAiring={isCurrentlyAiring(program)}
onPress={() => onProgramPress(program)}
disabled={disabled}
refSetter={index === 0 ? firstProgramRefSetter : undefined}
/>
</View>
))}
{/* Empty state */}
{programCells.length === 0 && (
<View style={[styles.noPrograms, { width: totalWidth - 8 }]}>
{/* Empty row indicator */}
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
height: 80,
position: "relative",
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.2)",
backgroundColor: "rgba(20, 20, 20, 1)",
},
programCellWrapper: {
position: "absolute",
top: 4,
bottom: 4,
},
noPrograms: {
position: "absolute",
left: 4,
top: 4,
bottom: 4,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 8,
},
});

View File

@@ -0,0 +1,154 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVGuidePageNavigationProps {
currentPage: number;
totalPages: number;
onPrevious: () => void;
onNext: () => void;
disabled?: boolean;
prevButtonRefSetter?: (ref: View | null) => void;
}
interface NavButtonProps {
onPress: () => void;
icon: keyof typeof Ionicons.glyphMap;
label: string;
isDisabled: boolean;
disabled?: boolean;
refSetter?: (ref: View | null) => void;
}
const NavButton: React.FC<NavButtonProps> = ({
onPress,
icon,
label,
isDisabled,
disabled = false,
refSetter,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const visuallyDisabled = isDisabled || disabled;
const handlePress = () => {
if (!visuallyDisabled) {
onPress();
}
};
return (
<Pressable
ref={refSetter}
onPress={handlePress}
onFocus={handleFocus}
onBlur={handleBlur}
focusable={!disabled}
>
<Animated.View
style={[
styles.navButton,
animatedStyle,
{
backgroundColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
opacity: visuallyDisabled ? 0.3 : 1,
},
]}
>
<Ionicons
name={icon}
size={20}
color={focused ? "#000000" : "#FFFFFF"}
/>
<Text
style={[
styles.navButtonText,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVGuidePageNavigation: React.FC<TVGuidePageNavigationProps> = ({
currentPage,
totalPages,
onPrevious,
onNext,
disabled = false,
prevButtonRefSetter,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
return (
<View style={styles.container}>
<View style={styles.buttonsContainer}>
<NavButton
onPress={onPrevious}
icon='chevron-back'
label={t("live_tv.previous")}
isDisabled={currentPage <= 1}
disabled={disabled}
refSetter={prevButtonRefSetter}
/>
<NavButton
onPress={onNext}
icon='chevron-forward'
label={t("live_tv.next")}
isDisabled={currentPage >= totalPages}
disabled={disabled}
/>
</View>
<Text style={[styles.pageText, { fontSize: typography.callout }]}>
{t("live_tv.page_of", { current: currentPage, total: totalPages })}
</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 16,
},
buttonsContainer: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
navButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
},
navButtonText: {
fontWeight: "600",
},
pageText: {
color: "rgba(255, 255, 255, 0.6)",
},
});

View File

@@ -0,0 +1,148 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVGuideProgramCellProps {
program: BaseItemDto;
width: number;
isCurrentlyAiring: boolean;
onPress: () => void;
disabled?: boolean;
refSetter?: (ref: View | null) => void;
}
export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
program,
width,
isCurrentlyAiring,
onPress,
disabled = false,
refSetter,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
scaleAmount: 1,
duration: 120,
});
const formatTime = (date: string | null | undefined) => {
if (!date) return "";
const d = new Date(date);
return d.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return (
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
style={{ width }}
>
<Animated.View
style={[
styles.container,
{
backgroundColor: focused
? "#FFFFFF"
: isCurrentlyAiring
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.08)",
borderColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
},
focused && styles.focusedShadow,
]}
>
{/* LIVE badge */}
{isCurrentlyAiring && (
<View style={styles.liveBadge}>
<Text
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
>
LIVE
</Text>
</View>
)}
{/* Program name */}
<Text
numberOfLines={2}
style={[
styles.programName,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{program.Name}
</Text>
{/* Time range */}
<Text
numberOfLines={1}
style={[
styles.timeText,
{
fontSize: typography.callout,
color: focused
? "rgba(0, 0, 0, 0.6)"
: "rgba(255, 255, 255, 0.5)",
},
]}
>
{formatTime(program.StartDate)} - {formatTime(program.EndDate)}
</Text>
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
container: {
height: 70,
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 8,
justifyContent: "center",
overflow: "hidden",
},
focusedShadow: {
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 12,
},
liveBadge: {
position: "absolute",
top: 6,
right: 6,
backgroundColor: "#EF4444",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
zIndex: 10,
elevation: 10,
},
liveBadgeText: {
color: "#FFFFFF",
fontWeight: "bold",
},
programName: {
fontWeight: "600",
marginBottom: 4,
},
timeText: {
fontWeight: "400",
},
});

View File

@@ -0,0 +1,64 @@
import { BlurView } from "expo-blur";
import React from "react";
import { StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVGuideTimeHeaderProps {
baseTime: Date;
hoursToShow: number;
pixelsPerHour: number;
}
export const TVGuideTimeHeader: React.FC<TVGuideTimeHeaderProps> = ({
baseTime,
hoursToShow,
pixelsPerHour,
}) => {
const typography = useScaledTVTypography();
const hours: Date[] = [];
for (let i = 0; i < hoursToShow; i++) {
const hour = new Date(baseTime);
hour.setMinutes(0, 0, 0);
hour.setHours(baseTime.getHours() + i);
hours.push(hour);
}
const formatHour = (date: Date) => {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return (
<BlurView intensity={40} tint='dark' style={styles.container}>
{hours.map((hour, index) => (
<View key={index} style={[styles.hourCell, { width: pixelsPerHour }]}>
<Text style={[styles.hourText, { fontSize: typography.callout }]}>
{formatHour(hour)}
</Text>
</View>
))}
</BlurView>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
height: 44,
},
hourCell: {
justifyContent: "center",
paddingLeft: 12,
borderLeftWidth: 1,
borderLeftColor: "rgba(255, 255, 255, 0.1)",
},
hourText: {
color: "rgba(255, 255, 255, 0.6)",
fontWeight: "500",
},
});

View File

@@ -0,0 +1,433 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { TVGuideChannelRow } from "./TVGuideChannelRow";
import { TVGuidePageNavigation } from "./TVGuidePageNavigation";
import { TVGuideTimeHeader } from "./TVGuideTimeHeader";
// Design constants
const CHANNEL_COLUMN_WIDTH = 240;
const PIXELS_PER_HOUR = 250;
const ROW_HEIGHT = 80;
const TIME_HEADER_HEIGHT = 44;
const CHANNELS_PER_PAGE = 20;
const MIN_PROGRAM_WIDTH = 80;
const HORIZONTAL_PADDING = 60;
// Channel label component
const ChannelLabel: React.FC<{
channel: BaseItemDto;
typography: ReturnType<typeof useScaledTVTypography>;
}> = ({ channel, typography }) => (
<View style={styles.channelLabel}>
<Text
numberOfLines={1}
style={[styles.channelNumber, { fontSize: typography.callout }]}
>
{channel.ChannelNumber}
</Text>
<Text
numberOfLines={1}
style={[styles.channelName, { fontSize: typography.callout }]}
>
{channel.Name}
</Text>
</View>
);
export const TVLiveTVGuide: React.FC = () => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const router = useRouter();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [currentPage, setCurrentPage] = useState(1);
// Scroll refs for synchronization
const channelListRef = useRef<ScrollView>(null);
const mainVerticalRef = useRef<ScrollView>(null);
// Focus guide refs for bidirectional navigation
const [firstProgramRef, setFirstProgramRef] = useState<View | null>(null);
const [prevButtonRef, setPrevButtonRef] = useState<View | null>(null);
// Base time - start of current hour, end time - end of day
const [{ baseTime, endOfDay, hoursToShow }] = useState(() => {
const now = new Date();
now.setMinutes(0, 0, 0);
const endOfDayTime = new Date(now);
endOfDayTime.setHours(23, 59, 59, 999);
const hoursUntilEndOfDay = Math.ceil(
(endOfDayTime.getTime() - now.getTime()) / (60 * 60 * 1000),
);
return {
baseTime: now,
endOfDay: endOfDayTime,
hoursToShow: Math.max(hoursUntilEndOfDay, 1), // At least 1 hour
};
});
// Current time indicator position (relative to program grid start)
const [currentTimeOffset, setCurrentTimeOffset] = useState(0);
// Update current time indicator every minute
useEffect(() => {
const updateCurrentTime = () => {
const now = new Date();
const diffMinutes = (now.getTime() - baseTime.getTime()) / 60000;
const offset = (diffMinutes / 60) * PIXELS_PER_HOUR;
setCurrentTimeOffset(offset);
};
updateCurrentTime();
const interval = setInterval(updateCurrentTime, 60000);
return () => clearInterval(interval);
}, [baseTime]);
// Sync vertical scroll between channel list and main grid
const handleVerticalScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = event.nativeEvent.contentOffset.y;
channelListRef.current?.scrollTo({ y: offsetY, animated: false });
},
[],
);
// Fetch channels
const { data: channelsData, isLoading: isLoadingChannels } = useQuery({
queryKey: ["livetv", "tv-guide", "channels"],
queryFn: async () => {
if (!api || !user?.Id) return null;
const res = await getLiveTvApi(api).getLiveTvChannels({
enableFavoriteSorting: true,
userId: user.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
enabled: !!api && !!user?.Id,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const totalChannels = channelsData?.TotalRecordCount ?? 0;
const totalPages = Math.ceil(totalChannels / CHANNELS_PER_PAGE);
const allChannels = channelsData?.Items ?? [];
// Get channels for current page
const paginatedChannels = useMemo(() => {
const startIndex = (currentPage - 1) * CHANNELS_PER_PAGE;
return allChannels.slice(startIndex, startIndex + CHANNELS_PER_PAGE);
}, [allChannels, currentPage]);
const channelIds = useMemo(
() => paginatedChannels.map((c) => c.Id).filter(Boolean) as string[],
[paginatedChannels],
);
// Fetch programs for visible channels
const { data: programsData, isLoading: isLoadingPrograms } = useQuery({
queryKey: [
"livetv",
"tv-guide",
"programs",
channelIds,
baseTime.toISOString(),
endOfDay.toISOString(),
],
queryFn: async () => {
if (!api || channelIds.length === 0) return null;
const res = await getLiveTvApi(api).getPrograms({
getProgramsDto: {
MaxStartDate: endOfDay.toISOString(),
MinEndDate: baseTime.toISOString(),
ChannelIds: channelIds,
ImageTypeLimit: 1,
EnableImages: false,
SortBy: ["StartDate"],
EnableTotalRecordCount: false,
EnableUserData: false,
},
});
return res.data;
},
enabled: channelIds.length > 0,
staleTime: 2 * 60 * 1000, // 2 minutes
});
const programs = programsData?.Items ?? [];
// Group programs by channel
const programsByChannel = useMemo(() => {
const grouped: Record<string, BaseItemDto[]> = {};
for (const program of programs) {
const channelId = program.ChannelId;
if (channelId) {
if (!grouped[channelId]) {
grouped[channelId] = [];
}
grouped[channelId].push(program);
}
}
return grouped;
}, [programs]);
const handleProgramPress = useCallback(
(program: BaseItemDto) => {
// Navigate to play the program/channel
const queryParams = new URLSearchParams({
itemId: program.Id ?? "",
audioIndex: "",
subtitleIndex: "",
mediaSourceId: "",
bitrateValue: "",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
},
[router],
);
const handlePreviousPage = useCallback(() => {
if (currentPage > 1) {
setCurrentPage((p) => p - 1);
}
}, [currentPage]);
const handleNextPage = useCallback(() => {
if (currentPage < totalPages) {
setCurrentPage((p) => p + 1);
}
}, [currentPage, totalPages]);
const isLoading = isLoadingChannels;
const totalWidth = hoursToShow * PIXELS_PER_HOUR;
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#FFFFFF' />
</View>
);
}
if (paginatedChannels.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { fontSize: typography.body }]}>
{t("live_tv.no_programs")}
</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Page Navigation */}
{totalPages > 1 && (
<View style={{ paddingHorizontal: insets.left + HORIZONTAL_PADDING }}>
<TVGuidePageNavigation
currentPage={currentPage}
totalPages={totalPages}
onPrevious={handlePreviousPage}
onNext={handleNextPage}
prevButtonRefSetter={setPrevButtonRef}
/>
</View>
)}
{/* Bidirectional focus guides */}
{firstProgramRef && (
<TVFocusGuideView
destinations={[firstProgramRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{prevButtonRef && (
<TVFocusGuideView
destinations={[prevButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Main grid container */}
<View style={styles.gridWrapper}>
{/* Fixed channel column */}
<View
style={[
styles.channelColumn,
{
width: CHANNEL_COLUMN_WIDTH,
marginLeft: insets.left + HORIZONTAL_PADDING,
},
]}
>
{/* Spacer for time header */}
<View style={{ height: TIME_HEADER_HEIGHT }} />
{/* Channel labels - synced with main scroll */}
<ScrollView
ref={channelListRef}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 60 }}
>
{paginatedChannels.map((channel, index) => (
<ChannelLabel
key={channel.Id ?? index}
channel={channel}
typography={typography}
/>
))}
</ScrollView>
</View>
{/* Scrollable programs area */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.horizontalScroll}
contentContainerStyle={{
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ width: totalWidth, position: "relative" }}>
{/* Time header */}
<TVGuideTimeHeader
baseTime={baseTime}
hoursToShow={hoursToShow}
pixelsPerHour={PIXELS_PER_HOUR}
/>
{/* Programs grid - vertical scroll */}
<ScrollView
ref={mainVerticalRef}
onScroll={handleVerticalScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 60 }}
>
{paginatedChannels.map((channel, index) => {
const channelPrograms = channel.Id
? (programsByChannel[channel.Id] ?? [])
: [];
return (
<TVGuideChannelRow
key={channel.Id ?? index}
programs={channelPrograms}
baseTime={baseTime}
pixelsPerHour={PIXELS_PER_HOUR}
minProgramWidth={MIN_PROGRAM_WIDTH}
hoursToShow={hoursToShow}
onProgramPress={handleProgramPress}
firstProgramRefSetter={
index === 0 ? setFirstProgramRef : undefined
}
/>
);
})}
</ScrollView>
{/* Current time indicator */}
{currentTimeOffset > 0 && currentTimeOffset < totalWidth && (
<View
style={[
styles.currentTimeIndicator,
{
left: currentTimeOffset,
top: 0,
height:
TIME_HEADER_HEIGHT +
paginatedChannels.length * ROW_HEIGHT,
},
]}
/>
)}
</View>
</ScrollView>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyText: {
color: "rgba(255, 255, 255, 0.6)",
},
gridWrapper: {
flex: 1,
flexDirection: "row",
},
channelColumn: {
backgroundColor: "rgba(40, 40, 40, 1)",
borderRightWidth: 1,
borderRightColor: "rgba(255, 255, 255, 0.2)",
},
channelLabel: {
height: ROW_HEIGHT,
justifyContent: "center",
paddingHorizontal: 12,
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.2)",
},
channelNumber: {
color: "rgba(255, 255, 255, 0.5)",
fontWeight: "400",
marginBottom: 2,
},
channelName: {
color: "#FFFFFF",
fontWeight: "600",
},
horizontalScroll: {
flex: 1,
},
currentTimeIndicator: {
position: "absolute",
width: 2,
backgroundColor: "#EF4444",
zIndex: 10,
pointerEvents: "none",
},
});

View File

@@ -0,0 +1,265 @@
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { TVChannelsGrid } from "@/components/livetv/TVChannelsGrid";
import { TVLiveTVGuide } from "@/components/livetv/TVLiveTVGuide";
import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder";
import { TVTabButton } from "@/components/tv/TVTabButton";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
const SECTION_GAP = 24;
type TabId =
| "programs"
| "guide"
| "channels"
| "recordings"
| "schedule"
| "series";
interface Tab {
id: TabId;
labelKey: string;
}
const TABS: Tab[] = [
{ id: "programs", labelKey: "live_tv.tabs.programs" },
{ id: "guide", labelKey: "live_tv.tabs.guide" },
{ id: "channels", labelKey: "live_tv.tabs.channels" },
{ id: "recordings", labelKey: "live_tv.tabs.recordings" },
{ id: "schedule", labelKey: "live_tv.tabs.schedule" },
{ id: "series", labelKey: "live_tv.tabs.series" },
];
export const TVLiveTVPage: React.FC = () => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [activeTab, setActiveTab] = useState<TabId>("programs");
// Section configurations for Programs tab
const sections = useMemo(() => {
if (!api || !user?.Id) return [];
return [
{
title: t("live_tv.on_now"),
queryKey: ["livetv", "tv", "onNow"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getRecommendedPrograms({
userId: user.Id,
isAiring: true,
limit: 24,
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.shows"),
queryKey: ["livetv", "tv", "shows"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isMovie: false,
isSeries: true,
isSports: false,
isNews: false,
isKids: false,
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.movies"),
queryKey: ["livetv", "tv", "movies"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isMovie: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.sports"),
queryKey: ["livetv", "tv", "sports"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isSports: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.for_kids"),
queryKey: ["livetv", "tv", "kids"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isKids: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.news"),
queryKey: ["livetv", "tv", "news"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isNews: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
];
}, [api, user?.Id, t]);
const handleTabSelect = useCallback((tabId: TabId) => {
setActiveTab(tabId);
}, []);
const renderProgramsContent = () => (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: 24,
paddingBottom: insets.bottom + 60,
}}
>
<View
style={{
gap: SECTION_GAP,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
}}
>
{sections.map((section) => (
<InfiniteScrollingCollectionList
key={section.queryKey.join("-")}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation='horizontal'
hideIfEmpty
pageSize={10}
enabled={true}
isFirstSection={false}
/>
))}
</View>
</ScrollView>
);
const renderTabContent = () => {
if (activeTab === "programs") {
return renderProgramsContent();
}
if (activeTab === "guide") {
return <TVLiveTVGuide />;
}
if (activeTab === "channels") {
return <TVChannelsGrid />;
}
// Placeholder for other tabs
const tab = TABS.find((t) => t.id === activeTab);
return <TVLiveTVPlaceholder tabName={t(tab?.labelKey || "")} />;
};
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Header with Title and Tabs */}
<View
style={{
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingBottom: 24,
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 24,
}}
>
Live TV
</Text>
{/* Tab Bar */}
<View
style={{
flexDirection: "row",
gap: 8,
}}
>
{TABS.map((tab) => (
<TVTabButton
key={tab.id}
label={t(tab.labelKey)}
active={activeTab === tab.id}
onSelect={() => handleTabSelect(tab.id)}
hasTVPreferredFocus={activeTab === tab.id}
switchOnFocus={true}
/>
))}
</View>
</View>
{/* Tab Content */}
<View style={{ flex: 1 }}>{renderTabContent()}</View>
</View>
);
};

View File

@@ -0,0 +1,46 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVLiveTVPlaceholderProps {
tabName: string;
}
export const TVLiveTVPlaceholder: React.FC<TVLiveTVPlaceholderProps> = ({
tabName,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 60,
}}
>
<Text
style={{
fontSize: typography.title,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 12,
}}
>
{tabName}
</Text>
<Text
style={{
fontSize: typography.body,
color: "rgba(255, 255, 255, 0.6)",
}}
>
{t("live_tv.coming_soon")}
</Text>
</View>
);
};

View File

@@ -72,22 +72,24 @@ export const Login: React.FC = () => {
password: string;
} | null>(null);
// Handle URL params for server connection
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({
address: _apiUrl,
});
setTimeout(() => {
if (_username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, 0);
}
})();
}, [_apiUrl, _username, _password]);
}, [_apiUrl]);
// Handle auto-login when api is ready and credentials are provided via URL params
useEffect(() => {
if (api?.basePath && _apiUrl && _username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, [api?.basePath, _apiUrl, _username, _password]);
useEffect(() => {
navigation.setOptions({

View File

@@ -3,7 +3,6 @@ import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import type { SavedServerAccount } from "@/utils/secureCredentials";
interface TVAccountCardProps {
@@ -85,7 +84,7 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
@@ -143,7 +142,7 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
</View>
{/* Security Icon */}
<Ionicons name={getSecurityIcon()} size={24} color={Colors.primary} />
<Ionicons name={getSecurityIcon()} size={24} color='#fff' />
</View>
</Animated.View>
</Pressable>

View File

@@ -0,0 +1,82 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVAddIconProps {
label: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVAddIcon = React.forwardRef<View, TVAddIconProps>(
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: 160,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View
style={{
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.05)",
marginBottom: 14,
borderWidth: 2,
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
borderStyle: "dashed",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='add'
size={56}
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
/>
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,162 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVInput } from "./TVInput";
interface TVAddServerFormProps {
onConnect: (url: string) => Promise<void>;
onBack: () => void;
loading?: boolean;
disabled?: boolean;
}
const TVBackButton: React.FC<{
onPress: () => void;
label: string;
disabled?: boolean;
}> = ({ onPress, label, disabled = false }) => {
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.05 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={onPress}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
style={{ alignSelf: "flex-start", marginBottom: 24 }}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
}}
>
<Ionicons
name='chevron-back'
size={28}
color={isFocused ? "#000" : "#fff"}
/>
<Text
style={{
color: isFocused ? "#000" : "#fff",
fontSize: 20,
marginLeft: 4,
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
onConnect,
onBack,
loading = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const [serverURL, setServerURL] = useState("");
const handleConnect = async () => {
if (serverURL.trim()) {
await onConnect(serverURL.trim());
}
};
const isDisabled = disabled || loading;
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: 60,
}}
>
{/* Back Button */}
<TVBackButton
onPress={onBack}
label={t("common.back")}
disabled={isDisabled}
/>
{/* Server URL Input */}
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("server.server_url_placeholder")}
value={serverURL}
onChangeText={setServerURL}
keyboardType='url'
autoCapitalize='none'
textContentType='URL'
returnKeyType='done'
hasTVPreferredFocus
disabled={isDisabled}
/>
</View>
{/* Connect Button */}
<View style={{ marginBottom: 24 }}>
<Button
onPress={handleConnect}
loading={loading}
disabled={loading || !serverURL.trim()}
color='white'
>
{t("server.connect_button")}
</Button>
</View>
{/* Hint text */}
<Text
style={{
fontSize: typography.callout,
color: "#6B7280",
textAlign: "left",
paddingHorizontal: 8,
}}
>
{t("server.enter_url_to_jellyfin_server")}
</Text>
</View>
</ScrollView>
);
};

View File

@@ -0,0 +1,230 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVInput } from "./TVInput";
import { TVSaveAccountToggle } from "./TVSaveAccountToggle";
interface TVAddUserFormProps {
serverName: string;
serverAddress: string;
onLogin: (
username: string,
password: string,
saveAccount: boolean,
) => Promise<void>;
onQuickConnect: () => Promise<void>;
onBack: () => void;
loading?: boolean;
disabled?: boolean;
}
const TVBackButton: React.FC<{
onPress: () => void;
label: string;
disabled?: boolean;
}> = ({ onPress, label, disabled = false }) => {
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.05 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={onPress}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
style={{ alignSelf: "flex-start", marginBottom: 40 }}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
}}
>
<Ionicons
name='chevron-back'
size={28}
color={isFocused ? "#000" : "#fff"}
/>
<Text
style={{
color: isFocused ? "#000" : "#fff",
fontSize: 20,
marginLeft: 4,
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
serverName,
serverAddress,
onLogin,
onQuickConnect,
onBack,
loading = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const [credentials, setCredentials] = useState({
username: "",
password: "",
});
const [saveAccount, setSaveAccount] = useState(false);
const handleLogin = async () => {
if (credentials.username.trim()) {
await onLogin(credentials.username, credentials.password, saveAccount);
}
};
const isDisabled = disabled || loading;
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: 60,
}}
>
{/* Back Button */}
<TVBackButton
onPress={onBack}
label={t("common.back")}
disabled={isDisabled}
/>
{/* Title */}
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text style={{ color: "#fff" }}>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginBottom: 40,
}}
>
{serverAddress}
</Text>
{/* Username Input */}
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.username_placeholder")}
value={credentials.username}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
returnKeyType='next'
hasTVPreferredFocus
disabled={isDisabled}
/>
</View>
{/* Password Input */}
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.password_placeholder")}
value={credentials.password}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
secureTextEntry
autoCapitalize='none'
textContentType='password'
returnKeyType='done'
disabled={isDisabled}
/>
</View>
{/* Save Account Toggle */}
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
<TVSaveAccountToggle
value={saveAccount}
onValueChange={setSaveAccount}
label={t("save_account.save_for_later")}
disabled={isDisabled}
/>
</View>
{/* Login Button */}
<View style={{ marginBottom: 16 }}>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim() || loading}
color='white'
>
{t("login.login_button")}
</Button>
</View>
{/* Quick Connect Button */}
<Button
onPress={onQuickConnect}
color='black'
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</ScrollView>
);
};

View File

@@ -0,0 +1,82 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVBackIconProps {
label: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVBackIcon = React.forwardRef<View, TVBackIconProps>(
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: 160,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View
style={{
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.05)",
marginBottom: 14,
borderWidth: 2,
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
borderStyle: "dashed",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='arrow-back'
size={56}
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
/>
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -6,7 +6,6 @@ import {
TextInput,
type TextInputProps,
} from "react-native";
import { fontSize, size } from "react-native-responsive-sizes";
interface TVInputProps extends TextInputProps {
label?: string;
@@ -59,20 +58,25 @@ export const TVInput: React.FC<TVInputProps> = ({
<Animated.View
style={{
transform: [{ scale }],
borderRadius: size(20),
borderWidth: size(6),
borderColor: isFocused ? "#FFFFFF" : "#333333",
borderRadius: 12,
backgroundColor: isFocused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.08)",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
}}
>
<TextInput
ref={inputRef}
placeholder={displayPlaceholder}
placeholderTextColor='rgba(255,255,255,0.35)'
allowFontScaling={false}
style={[
{
height: size(200),
fontSize: fontSize(12),
height: 64,
fontSize: 22,
color: "#FFFFFF",
paddingHorizontal: 20,
},
style,
]}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -11,8 +12,6 @@ import {
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
import { useTVFocusAnimation } from "@/components/tv";
import { verifyAccountPIN } from "@/utils/secureCredentials";
interface TVPINEntryModalProps {
@@ -25,40 +24,122 @@ interface TVPINEntryModalProps {
username: string;
}
// Forgot PIN Button
const TVForgotPINButton: React.FC<{
// Number pad button
const NumberPadButton: React.FC<{
value: string;
onPress: () => void;
label: string;
hasTVPreferredFocus?: boolean;
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
isBackspace?: boolean;
disabled?: boolean;
}> = ({ value, onPress, hasTVPreferredFocus, isBackspace, disabled }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 100,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
onFocus={() => {
setFocused(true);
animateTo(1.1);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
styles.numberButton,
{
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: focused
? "rgba(168, 85, 247, 0.2)"
: "transparent",
transform: [{ scale }],
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
},
]}
>
{isBackspace ? (
<Ionicons
name='backspace-outline'
size={28}
color={focused ? "#000" : "#fff"}
/>
) : (
<Text
style={[styles.numberText, { color: focused ? "#000" : "#fff" }]}
>
{value}
</Text>
)}
</Animated.View>
</Pressable>
);
};
// PIN dot indicator
const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({
filled,
error,
}) => (
<View
style={[
styles.pinDot,
filled && styles.pinDotFilled,
error && styles.pinDotError,
]}
/>
);
// Forgot PIN link
const ForgotPINLink: React.FC<{
onPress: () => void;
label: string;
}> = ({ onPress, label }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 100,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<Animated.View
style={{
transform: [{ scale }],
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: focused ? "rgba(255,255,255,0.15)" : "transparent",
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#d8b4fe" : "#a855f7",
fontWeight: "500",
color: focused ? "#fff" : "rgba(255,255,255,0.5)",
}}
>
{label}
@@ -80,23 +161,21 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const [pinCode, setPinCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const pinInputRef = useRef<TVPinInputRef>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
const contentScale = useRef(new Animated.Value(0.9)).current;
const shakeAnimation = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
// Reset state when opening
setPinCode("");
setError(null);
setError(false);
setIsVerifying(false);
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
contentScale.setValue(0.9);
Animated.parallel([
Animated.timing(overlayOpacity, {
@@ -105,32 +184,19 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
Animated.timing(contentScale, {
toValue: 1,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
useEffect(() => {
if (visible && isReady) {
const timer = setTimeout(() => {
pinInputRef.current?.focus();
}, 150);
return () => clearTimeout(timer);
}
}, [visible, isReady]);
}, [visible, overlayOpacity, contentScale]);
const shake = () => {
Animated.sequence([
@@ -157,33 +223,42 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
]).start();
};
const handlePinChange = async (value: string) => {
setPinCode(value);
setError(null);
const handleNumberPress = async (num: string) => {
if (isVerifying || pinCode.length >= 4) return;
setError(false);
const newPin = pinCode + num;
setPinCode(newPin);
// Auto-verify when 4 digits entered
if (value.length === 4) {
if (newPin.length === 4) {
setIsVerifying(true);
try {
const isValid = await verifyAccountPIN(serverUrl, userId, value);
const isValid = await verifyAccountPIN(serverUrl, userId, newPin);
if (isValid) {
onSuccess();
setPinCode("");
} else {
setError(t("pin.invalid_pin"));
setError(true);
shake();
setPinCode("");
setTimeout(() => setPinCode(""), 300);
}
} catch {
setError(t("pin.invalid_pin"));
setError(true);
shake();
setPinCode("");
setTimeout(() => setPinCode(""), 300);
} finally {
setIsVerifying(false);
}
}
};
const handleBackspace = () => {
if (isVerifying) return;
setError(false);
setPinCode((prev) => prev.slice(0, -1));
};
const handleForgotPIN = () => {
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
{ text: t("common.cancel"), style: "cancel" },
@@ -204,11 +279,11 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
styles.contentContainer,
{ transform: [{ scale: contentScale }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<BlurView intensity={60} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
@@ -218,44 +293,103 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
style={styles.content}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
<Text style={styles.subtitle}>
{t("pin.enter_pin_for", { username })}
</Text>
</View>
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
<Text style={styles.subtitle}>{username}</Text>
{/* PIN Input */}
{/* PIN Dots */}
<Animated.View
style={[
styles.pinDotsContainer,
{ transform: [{ translateX: shakeAnimation }] },
]}
>
{[0, 1, 2, 3].map((i) => (
<PinDot key={i} filled={pinCode.length > i} error={error} />
))}
</Animated.View>
{/* Number Pad */}
{isReady && (
<Animated.View
style={[
styles.pinContainer,
{ transform: [{ translateX: shakeAnimation }] },
]}
>
<TVPinInput
ref={pinInputRef}
value={pinCode}
onChangeText={handlePinChange}
length={4}
autoFocus
/>
{error && <Text style={styles.errorText}>{error}</Text>}
{isVerifying && (
<Text style={styles.verifyingText}>
{t("common.verifying")}
</Text>
)}
</Animated.View>
<View style={styles.numberPad}>
{/* Row 1: 1-3 */}
<View style={styles.numberRow}>
<NumberPadButton
value='1'
onPress={() => handleNumberPress("1")}
hasTVPreferredFocus
disabled={isVerifying}
/>
<NumberPadButton
value='2'
onPress={() => handleNumberPress("2")}
disabled={isVerifying}
/>
<NumberPadButton
value='3'
onPress={() => handleNumberPress("3")}
disabled={isVerifying}
/>
</View>
{/* Row 2: 4-6 */}
<View style={styles.numberRow}>
<NumberPadButton
value='4'
onPress={() => handleNumberPress("4")}
disabled={isVerifying}
/>
<NumberPadButton
value='5'
onPress={() => handleNumberPress("5")}
disabled={isVerifying}
/>
<NumberPadButton
value='6'
onPress={() => handleNumberPress("6")}
disabled={isVerifying}
/>
</View>
{/* Row 3: 7-9 */}
<View style={styles.numberRow}>
<NumberPadButton
value='7'
onPress={() => handleNumberPress("7")}
disabled={isVerifying}
/>
<NumberPadButton
value='8'
onPress={() => handleNumberPress("8")}
disabled={isVerifying}
/>
<NumberPadButton
value='9'
onPress={() => handleNumberPress("9")}
disabled={isVerifying}
/>
</View>
{/* Row 4: empty, 0, backspace */}
<View style={styles.numberRow}>
<View style={styles.numberButtonPlaceholder} />
<NumberPadButton
value='0'
onPress={() => handleNumberPress("0")}
disabled={isVerifying}
/>
<NumberPadButton
value=''
onPress={handleBackspace}
isBackspace
disabled={isVerifying || pinCode.length === 0}
/>
</View>
</View>
)}
{/* Forgot PIN */}
{isReady && onForgotPIN && (
<View style={styles.forgotContainer}>
<TVForgotPINButton
<ForgotPINLink
onPress={handleForgotPIN}
label={t("pin.forgot_pin")}
hasTVPreferredFocus
/>
</View>
)}
@@ -273,55 +407,81 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
backgroundColor: "rgba(0, 0, 0, 0.8)",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
},
sheetContainer: {
contentContainer: {
width: "100%",
maxWidth: 400,
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
header: {
paddingHorizontal: 48,
marginBottom: 24,
padding: 40,
alignItems: "center",
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
marginBottom: 4,
marginBottom: 8,
textAlign: "center",
},
subtitle: {
fontSize: 16,
fontSize: 18,
color: "rgba(255,255,255,0.6)",
marginBottom: 32,
textAlign: "center",
},
pinContainer: {
paddingHorizontal: 48,
pinDotsContainer: {
flexDirection: "row",
gap: 16,
marginBottom: 32,
},
pinDot: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: "rgba(255,255,255,0.4)",
backgroundColor: "transparent",
},
pinDotFilled: {
backgroundColor: "#fff",
borderColor: "#fff",
},
pinDotError: {
borderColor: "#ef4444",
backgroundColor: "#ef4444",
},
numberPad: {
gap: 12,
marginBottom: 24,
},
numberRow: {
flexDirection: "row",
gap: 12,
},
numberButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
marginBottom: 16,
},
errorText: {
color: "#ef4444",
fontSize: 14,
marginTop: 16,
textAlign: "center",
numberButtonPlaceholder: {
width: 72,
height: 72,
},
verifyingText: {
color: "rgba(255,255,255,0.6)",
fontSize: 14,
marginTop: 16,
textAlign: "center",
numberText: {
fontSize: 28,
fontWeight: "600",
},
forgotContainer: {
alignItems: "center",
marginTop: 8,
},
});

View File

@@ -47,10 +47,10 @@ const TVSubmitButton: React.FC<{
animatedStyle,
{
backgroundColor: focused
? "#a855f7"
? "#fff"
: isDisabled
? "#4a4a4a"
: "#7c3aed",
: "rgba(255,255,255,0.15)",
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 10,
@@ -64,14 +64,18 @@ const TVSubmitButton: React.FC<{
]}
>
{loading ? (
<ActivityIndicator size='small' color='#fff' />
<ActivityIndicator size='small' color={focused ? "#000" : "#fff"} />
) : (
<>
<Ionicons name='log-in-outline' size={20} color='#fff' />
<Ionicons
name='log-in-outline'
size={20}
color={focused ? "#000" : "#fff"}
/>
<Text
style={{
fontSize: 16,
color: "#fff",
color: focused ? "#000" : "#fff",
fontWeight: "600",
}}
>
@@ -119,7 +123,7 @@ const TVPasswordInput: React.FC<{
backgroundColor: "#1F2937",
borderRadius: 12,
borderWidth: 2,
borderColor: focused ? "#6366F1" : "#374151",
borderColor: focused ? "#fff" : "#374151",
paddingHorizontal: 16,
paddingVertical: 14,
},
@@ -245,14 +249,16 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
{/* Password Input */}
{isReady && (
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>{t("login.password")}</Text>
<Text style={styles.inputLabel}>
{t("login.password_placeholder")}
</Text>
<TVPasswordInput
value={password}
onChangeText={(text) => {
setPassword(text);
setError(null);
}}
placeholder={t("login.password")}
placeholder={t("login.password_placeholder")}
onSubmitEditing={handleSubmit}
hasTVPreferredFocus
/>

View File

@@ -1,513 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Animated,
Easing,
Modal,
Pressable,
ScrollView,
View,
} from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { fontSize, height, size, width } from "react-native-responsive-sizes";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import {
deleteAccountCredential,
getPreviousServers,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { TVAccountCard } from "./TVAccountCard";
import { TVServerCard } from "./TVServerCard";
// Action card for server action sheet (Apple TV style)
const TVServerActionCard: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isDestructive = variant === "destructive";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: width(25),
height: height(15),
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: size(20),
justifyContent: "center",
alignItems: "center",
paddingHorizontal: size(12),
gap: size(8),
}}
>
<Ionicons
name={icon}
size={size(56)}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: fontSize(12),
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
textAlign: "center",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
// Server action sheet component (bottom sheet with horizontal scrolling)
const TVServerActionSheet: React.FC<{
visible: boolean;
server: SavedServer | null;
onLogin: () => void;
onDelete: () => void;
onClose: () => void;
}> = ({ visible, server, onLogin, onDelete, onClose }) => {
const { t } = useTranslation();
if (!server) return null;
return (
<Modal
visible={visible}
transparent
animationType='fade'
onRequestClose={onClose}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: size(24),
borderTopRightRadius: size(24),
overflow: "hidden",
}}
>
<View
style={{
paddingTop: size(24),
paddingBottom: size(50),
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: fontSize(12),
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: size(12),
paddingHorizontal: size(48),
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{server.name || server.address}
</Text>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: size(48),
paddingVertical: size(10),
gap: size(12),
}}
>
<TVServerActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={onLogin}
/>
<TVServerActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={onDelete}
/>
<TVServerActionCard
label={t("common.cancel")}
icon='close-outline'
onPress={onClose}
/>
</ScrollView>
</View>
</BlurView>
</View>
</Modal>
);
};
interface TVPreviousServersListProps {
onServerSelect: (server: SavedServer) => void;
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
onPasswordLogin?: (
serverUrl: string,
username: string,
password: string,
) => Promise<void>;
onAddAccount?: (server: SavedServer) => void;
onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void;
onPasswordRequired?: (
server: SavedServer,
account: SavedServerAccount,
) => void;
// Called when server is pressed to show action sheet (handled by parent)
onServerAction?: (server: SavedServer) => void;
// Called by parent when "Login" is selected from action sheet
loginServerOverride?: SavedServer | null;
// Disable all focusable elements (when a modal is open)
disabled?: boolean;
}
// Export the action sheet for use in parent components
export { TVServerActionSheet };
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
onServerSelect,
onQuickLogin,
onAddAccount,
onPinRequired,
onPasswordRequired,
onServerAction,
loginServerOverride,
disabled = false,
}) => {
const { t } = useTranslation();
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [showAccountsModal, setShowAccountsModal] = useState(false);
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]);
// When parent triggers login via loginServerOverride, execute the login flow
useEffect(() => {
if (loginServerOverride) {
const accountCount = loginServerOverride.accounts?.length || 0;
if (accountCount === 0) {
onServerSelect(loginServerOverride);
} else if (accountCount === 1) {
handleAccountLogin(
loginServerOverride,
loginServerOverride.accounts[0],
);
} else {
setSelectedServer(loginServerOverride);
setShowAccountsModal(true);
}
}
}, [loginServerOverride]);
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
};
const handleAccountLogin = async (
server: SavedServer,
account: SavedServerAccount,
) => {
setShowAccountsModal(false);
switch (account.securityType) {
case "none":
if (onQuickLogin) {
setLoadingServer(server.address);
try {
await onQuickLogin(server.address, account.userId);
} catch {
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
);
} finally {
setLoadingServer(null);
}
}
break;
case "pin":
if (onPinRequired) {
onPinRequired(server, account);
}
break;
case "password":
if (onPasswordRequired) {
onPasswordRequired(server, account);
}
break;
}
};
const handleServerPress = (server: SavedServer) => {
if (loadingServer) return;
// If onServerAction is provided, delegate to parent for action sheet handling
if (onServerAction) {
onServerAction(server);
return;
}
// Fallback: direct login flow (for backwards compatibility)
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) {
onServerSelect(server);
} else if (accountCount === 1) {
handleAccountLogin(server, server.accounts[0]);
} else {
setSelectedServer(server);
setShowAccountsModal(true);
}
};
const getServerSubtitle = (server: SavedServer): string | undefined => {
const accountCount = server.accounts?.length || 0;
if (accountCount > 1) {
return t("server.accounts_count", { count: accountCount });
}
if (accountCount === 1) {
return `${server.accounts[0].username}${t("server.saved")}`;
}
return server.name ? server.address : undefined;
};
const getSecurityIcon = (
server: SavedServer,
): keyof typeof Ionicons.glyphMap | null => {
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) return null;
if (accountCount > 1) {
return "people";
}
const account = server.accounts[0];
switch (account.securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
const handleDeleteAccount = async (account: SavedServerAccount) => {
if (!selectedServer) return;
Alert.alert(
t("server.remove_saved_login"),
t("server.remove_account_description", { username: account.username }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.remove"),
style: "destructive",
onPress: async () => {
await deleteAccountCredential(
selectedServer.address,
account.userId,
);
refreshServers();
if (selectedServer.accounts.length <= 1) {
setShowAccountsModal(false);
}
},
},
],
);
};
if (!previousServers.length) return null;
return (
<View style={{ marginTop: size(100) }}>
<Text
style={{
fontSize: fontSize(12),
fontWeight: "600",
color: "#FFFFFF",
marginBottom: size(20),
}}
>
{t("server.previous_servers")}
</Text>
<View style={{ gap: size(12) }}>
{previousServers.map((server) => (
<TVServerCard
key={server.address}
title={server.name || server.address}
subtitle={getServerSubtitle(server)}
securityIcon={getSecurityIcon(server)}
isLoading={loadingServer === server.address}
onPress={() => handleServerPress(server)}
disabled={disabled}
/>
))}
</View>
{/* TV Account Selection Modal */}
<Modal
visible={showAccountsModal}
transparent
animationType='fade'
onRequestClose={() => setShowAccountsModal(false)}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.9)",
justifyContent: "center",
alignItems: "center",
padding: size(80),
}}
>
<View
style={{
backgroundColor: "#1a1a1a",
borderRadius: size(24),
padding: size(40),
width: "100%",
maxWidth: width(70),
}}
>
<Text
style={{
fontSize: fontSize(32),
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: size(8),
}}
>
{t("server.select_account")}
</Text>
<Text
style={{
fontSize: fontSize(18),
color: "#9CA3AF",
marginBottom: size(32),
}}
>
{selectedServer?.name || selectedServer?.address}
</Text>
<View style={{ gap: size(12), marginBottom: size(24) }}>
{selectedServer?.accounts.map((account, index) => (
<TVAccountCard
key={account.userId}
account={account}
onPress={() =>
selectedServer &&
handleAccountLogin(selectedServer, account)
}
onLongPress={() => handleDeleteAccount(account)}
hasTVPreferredFocus={index === 0}
/>
))}
</View>
<View style={{ gap: size(12) }}>
<Button
onPress={() => {
setShowAccountsModal(false);
if (selectedServer && onAddAccount) {
onAddAccount(selectedServer);
}
}}
color='purple'
>
{t("server.add_account")}
</Button>
<Button
onPress={() => setShowAccountsModal(false)}
color='black'
className='bg-neutral-800'
>
{t("common.cancel")}
</Button>
</View>
</View>
</View>
</Modal>
</View>
);
};

View File

@@ -75,10 +75,10 @@ const TVSaveButton: React.FC<{
animatedStyle,
{
backgroundColor: focused
? "#a855f7"
? "#fff"
: disabled
? "#4a4a4a"
: "#7c3aed",
: "rgba(255,255,255,0.15)",
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 10,
@@ -89,11 +89,15 @@ const TVSaveButton: React.FC<{
},
]}
>
<Ionicons name='checkmark' size={20} color='#fff' />
<Ionicons
name='checkmark'
size={20}
color={focused ? "#000" : "#fff"}
/>
<Text
style={{
fontSize: 16,
color: "#fff",
color: focused ? "#000" : "#fff",
fontWeight: "600",
}}
>

Some files were not shown because too many files have changed in this diff Show More