diff --git a/.claude/agents/tv-validator.md b/.claude/agents/tv-validator.md
new file mode 100644
index 00000000..a38dd751
--- /dev/null
+++ b/.claude/agents/tv-validator.md
@@ -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 ;
+}
+return ;
+```
+
+### 2. No FlashList on TV
+FlashList has focus issues on TV. Use FlatList instead.
+
+**Violation**: `
+) : (
+
+)}
+```
+
+### 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 `` in TV components
+**Correct**: `...`
+
+### 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]
+```
diff --git a/.claude/commands/reflect.md b/.claude/commands/reflect.md
index 2ee23479..deedf8d4 100644
--- a/.claude/commands/reflect.md
+++ b/.claude/commands/reflect.md
@@ -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).
diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md
index 67a2243c..ab5d6eec 100644
--- a/.claude/learned-facts.md
+++ b/.claude/learned-facts.md
@@ -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)_
\ No newline at end of file
+- **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)_
\ No newline at end of file
diff --git a/.claude/learned-facts/header-button-locations.md b/.claude/learned-facts/header-button-locations.md
new file mode 100644
index 00000000..269b51f1
--- /dev/null
+++ b/.claude/learned-facts/header-button-locations.md
@@ -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`.
diff --git a/.claude/learned-facts/intro-modal-trigger-location.md b/.claude/learned-facts/intro-modal-trigger-location.md
new file mode 100644
index 00000000..4409db06
--- /dev/null
+++ b/.claude/learned-facts/intro-modal-trigger-location.md
@@ -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.
diff --git a/.claude/learned-facts/introsheet-rendering-location.md b/.claude/learned-facts/introsheet-rendering-location.md
new file mode 100644
index 00000000..b9575cd7
--- /dev/null
+++ b/.claude/learned-facts/introsheet-rendering-location.md
@@ -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.
diff --git a/.claude/learned-facts/macos-header-buttons-fix.md b/.claude/learned-facts/macos-header-buttons-fix.md
new file mode 100644
index 00000000..45d5f31a
--- /dev/null
+++ b/.claude/learned-facts/macos-header-buttons-fix.md
@@ -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.
diff --git a/.claude/learned-facts/mark-as-played-flow.md b/.claude/learned-facts/mark-as-played-flow.md
new file mode 100644
index 00000000..48603cd0
--- /dev/null
+++ b/.claude/learned-facts/mark-as-played-flow.md
@@ -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`.
diff --git a/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md b/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md
new file mode 100644
index 00000000..418f862a
--- /dev/null
+++ b/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md
@@ -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).
diff --git a/.claude/learned-facts/mpv-tvos-player-exit-freeze.md b/.claude/learned-facts/mpv-tvos-player-exit-freeze.md
new file mode 100644
index 00000000..7dfb2017
--- /dev/null
+++ b/.claude/learned-facts/mpv-tvos-player-exit-freeze.md
@@ -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.
diff --git a/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md b/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md
new file mode 100644
index 00000000..eda49ef0
--- /dev/null
+++ b/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md
@@ -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.
diff --git a/.claude/learned-facts/native-swiftui-view-sizing.md b/.claude/learned-facts/native-swiftui-view-sizing.md
new file mode 100644
index 00000000..f36a1837
--- /dev/null
+++ b/.claude/learned-facts/native-swiftui-view-sizing.md
@@ -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.
diff --git a/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md b/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md
new file mode 100644
index 00000000..d52dca9b
--- /dev/null
+++ b/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md
@@ -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.
diff --git a/.claude/learned-facts/stack-screen-header-configuration.md b/.claude/learned-facts/stack-screen-header-configuration.md
new file mode 100644
index 00000000..24ca01fc
--- /dev/null
+++ b/.claude/learned-facts/stack-screen-header-configuration.md
@@ -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.
diff --git a/.claude/learned-facts/streamystats-components-location.md b/.claude/learned-facts/streamystats-components-location.md
new file mode 100644
index 00000000..41652a52
--- /dev/null
+++ b/.claude/learned-facts/streamystats-components-location.md
@@ -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`.
diff --git a/.claude/learned-facts/tab-folder-naming.md b/.claude/learned-facts/tab-folder-naming.md
new file mode 100644
index 00000000..7663a609
--- /dev/null
+++ b/.claude/learned-facts/tab-folder-naming.md
@@ -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.
diff --git a/.claude/learned-facts/thread-safe-state-for-stop-flags.md b/.claude/learned-facts/thread-safe-state-for-stop-flags.md
new file mode 100644
index 00000000..eaa0d84d
--- /dev/null
+++ b/.claude/learned-facts/thread-safe-state-for-stop-flags.md
@@ -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.
diff --git a/.claude/learned-facts/tv-grid-layout-pattern.md b/.claude/learned-facts/tv-grid-layout-pattern.md
new file mode 100644
index 00000000..6f9b234a
--- /dev/null
+++ b/.claude/learned-facts/tv-grid-layout-pattern.md
@@ -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.
diff --git a/.claude/learned-facts/tv-horizontal-padding-standard.md b/.claude/learned-facts/tv-horizontal-padding-standard.md
new file mode 100644
index 00000000..e9ddc0c8
--- /dev/null
+++ b/.claude/learned-facts/tv-horizontal-padding-standard.md
@@ -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.
diff --git a/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md b/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md
new file mode 100644
index 00000000..c6c837d5
--- /dev/null
+++ b/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md
@@ -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.
diff --git a/.claude/learned-facts/use-network-aware-query-client-limitations.md b/.claude/learned-facts/use-network-aware-query-client-limitations.md
new file mode 100644
index 00000000..36e8f2d8
--- /dev/null
+++ b/.claude/learned-facts/use-network-aware-query-client-limitations.md
@@ -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`.
diff --git a/.gitignore b/.gitignore
index b8c7526a..e7f89813 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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-*
diff --git a/CLAUDE.md b/CLAUDE.md
index 10e4f559..eb2ae87e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
-
-
- {/* Content */}
-
-
-```
-
-2. **Horizontal ScrollView with focusable cards**:
-```typescript
-
- {options.map((option, index) => (
- { onSelect(option.value); onClose(); }}
- // ...
- />
- ))}
-
-```
-
-3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`:
-```typescript
- { setFocused(true); animateTo(1.05); }}
- onBlur={() => { setFocused(false); animateTo(1); }}
- hasTVPreferredFocus={hasTVPreferredFocus}
->
-
- {label}
-
-
-```
-
-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(null);
-const isModalOpen = openModal !== null;
-
-// 2. Each focusable component accepts disabled prop
-const TVFocusableButton: React.FC<{
- onPress: () => void;
- disabled?: boolean;
-}> = ({ onPress, disabled }) => (
-
- {/* content */}
-
-);
-
-// 3. Pass disabled to all background components when modal is open
-
-```
-
-**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)
diff --git a/app.json b/app.json
index 668c6163..f9dfbb6d 100644
--- a/app.json
+++ b/app.json
@@ -76,6 +76,7 @@
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.js",
+ "./plugins/withTVUserManagement.js",
[
"expo-build-properties",
{
diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index ad951c36..9586d465 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -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 ;
- }
-
return ;
};
diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx
index e311c582..9dd56673 100644
--- a/app/(auth)/(tabs)/(home)/settings.tv.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx
@@ -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(
+ null,
+ );
+ const [selectedAccount, setSelectedAccount] =
+ useState(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[] = useMemo(
@@ -130,6 +263,123 @@ export default function SettingsTV() {
[currentAlignY],
);
+ // Cache mode options
+ const cacheModeOptions: TVOptionItem[] = 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[] = 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[] = 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[] = 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 (
@@ -166,7 +439,7 @@ export default function SettingsTV() {
{/* Header */}
+ {/* Account Section */}
+
+
+
+ {/* Security Section */}
+
+
+ showOptions({
+ title: t("home.settings.security.inactivity_timeout.title"),
+ options: inactivityTimeoutOptions,
+ onSelect: (value) =>
+ updateSettings({ inactivityTimeout: value }),
+ })
+ }
+ />
+
{/* Audio Section */}
{/* Subtitles Section */}
@@ -215,26 +512,10 @@ export default function SettingsTV() {
/>
{
- 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 */}
-
- {
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() {
+ {/* Buffer Settings Section */}
+
+
+ showOptions({
+ title: t("home.settings.buffer.cache_mode"),
+ options: cacheModeOptions,
+ onSelect: (value) => updateSettings({ mpvCacheEnabled: value }),
+ })
+ }
+ />
+ {
+ 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`}
+ />
+ {
+ 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`}
+ />
+ {
+ 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 */}
+
+ showOptions({
+ title: t("home.settings.appearance.display_size"),
+ options: typographyScaleOptions,
+ onSelect: (value) =>
+ updateSettings({ tvTypographyScale: value }),
+ })
+ }
+ />
+
+ showOptions({
+ title: t("home.settings.languages.app_language"),
+ options: languageOptions,
+ onSelect: (value) =>
+ updateSettings({ preferedLanguage: value }),
+ })
+ }
+ />
updateSettings({ showHomeBackdrop: value })}
/>
+ updateSettings({ showTVHeroCarousel: value })}
+ />
+
+ updateSettings({ showSeriesPosterOnEpisode: value })
+ }
+ />
+ updateSettings({ tvThemeMusicEnabled: value })}
+ />
{/* User Section */}
+
+ {/* PIN Entry Modal */}
+ {
+ 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 */}
+ {
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ onSubmit={handlePasswordSubmit}
+ username={selectedAccount?.username || ""}
+ />
);
}
diff --git a/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx b/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
index 370247c1..6affc834 100644
--- a/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
@@ -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() {
+
{!Platform.isTV && }
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
index 1723fe4b..fe44932b 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
@@ -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 = () => {
}}
>
- {/* */}
@@ -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 (
+
+ showItemActions(item)}
+ width={posterSizes.poster}
+ />
+
+ );
+ },
+ [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[] => [
+ {
+ 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[] => [
+ {
+ 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[] => [
+ {
+ 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[] =>
+ sortOptions.map((option) => ({
+ label: option.value,
+ value: option.key,
+ selected: sortBy[0] === option.key,
+ })),
+ [sortBy],
+ );
+
+ const tvSortOrderOptions = useMemo(
+ (): TVOptionItem[] =>
+ 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 (
+
+
+
+ );
+ }
+
if (!collection) return null;
- return (
-
-
- {t("search.no_results")}
-
-
- }
- 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 (
+
+
+ {t("search.no_results")}
+
+
}
- }}
- onEndReachedThreshold={0.5}
- ListHeaderComponent={ListHeaderComponent}
- contentContainerStyle={{ paddingBottom: 24 }}
- ItemSeparatorComponent={() => (
- {
+ if (hasNextPage) {
+ fetchNextPage();
+ }
+ }}
+ onEndReachedThreshold={0.5}
+ ListHeaderComponent={ListHeaderComponent}
+ contentContainerStyle={{ paddingBottom: 24 }}
+ ItemSeparatorComponent={() => (
+
+ )}
+ />
+ );
+ }
+
+ // TV return with filter bar
+ return (
+
+ {/* Filter bar */}
+
+ {hasActiveFilters && (
+
+ )}
+ 0
+ ? `${selectedGenres.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowGenreFilter}
+ hasTVPreferredFocus={!hasActiveFilters}
+ hasActiveFilter={selectedGenres.length > 0}
/>
- )}
- />
+ 0
+ ? `${selectedYears.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowYearFilter}
+ hasActiveFilter={selectedYears.length > 0}
+ />
+ 0
+ ? `${selectedTags.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowTagFilter}
+ hasActiveFilter={selectedTags.length > 0}
+ />
+ o.key === sortBy[0])?.value || ""}
+ onPress={handleShowSortByFilter}
+ />
+ o.key === sortOrder[0])?.value || ""
+ }
+ onPress={handleShowSortOrderFilter}
+ />
+
+
+ {/* Grid */}
+
+
+ {t("search.no_results")}
+
+
+ }
+ 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={() => (
+
+ )}
+ />
+
);
};
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx
index 072b2f93..28cc2f9d 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx
@@ -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 (
+ <>
+
+
+ >
+ );
+ }
+
return (
<>
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx
index 812d084d..f1471e3a 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx
@@ -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 ;
+ }
+
+ return ;
+}
+
+function MobileLiveTVPrograms() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
index 32f5d1cd..cc4b21b9 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
@@ -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),
});
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index 5d7f63e9..ccf38d3e 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -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 }) => (
-
-
- {item.Name}
-
-
- {item.ProductionYear}
-
-
-);
-
-// TV Filter Types and Components
-type TVFilterModalType =
- | "genre"
- | "year"
- | "tags"
- | "sortBy"
- | "sortOrder"
- | "filterBy"
- | null;
-
-interface TVFilterOption {
- 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 (
- {
- setFocused(true);
- animateTo(1.05);
- }}
- onBlur={() => {
- setFocused(false);
- animateTo(1);
- }}
- hasTVPreferredFocus={hasTVPreferredFocus}
- >
-
-
- {label}
-
- {selected && !focused && (
-
-
-
- )}
-
-
- );
-};
-
-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 (
- {
- setFocused(true);
- animateTo(1.04);
- }}
- onBlur={() => {
- setFocused(false);
- animateTo(1);
- }}
- hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
- disabled={disabled}
- focusable={!disabled}
- >
-
-
- {label ? (
-
- {label}
-
- ) : null}
-
- {value}
-
-
-
-
- );
-};
-
-const TVFilterSelector = ({
- visible,
- title,
- options,
- onSelect,
- onClose,
-}: {
- visible: boolean;
- title: string;
- options: TVFilterOption[];
- onSelect: (value: T) => void;
- onClose: () => void;
-}) => {
- // Track initial focus index - only set once when modal opens
- const initialFocusIndexRef = useRef(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 (
-
-
-
-
- {title}
-
-
- {options.map((option, index) => (
- {
- onSelect(option.value);
- onClose();
- }}
- />
- ))}
-
-
-
-
- );
-};
+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(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 (
+
+ showItemActions(item)}
+ >
+
+
+
+
+
+
+ {item.Name}
+
+
+
+ );
+ }
+
return (
-
-
- {item.Type === "Movie" && }
- {(item.Type === "Series" || item.Type === "Episode") && (
-
- )}
- {item.Type !== "Movie" &&
- item.Type !== "Series" &&
- item.Type !== "Episode" && }
-
-
-
+ 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[] => [
+ (): TVOptionItem[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -928,7 +700,7 @@ const Page = () => {
);
const tvYearFilterOptions = useMemo(
- (): TVFilterOption[] => [
+ (): TVOptionItem[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -944,7 +716,7 @@ const Page = () => {
);
const tvTagFilterOptions = useMemo(
- (): TVFilterOption[] => [
+ (): TVOptionItem[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -960,7 +732,7 @@ const Page = () => {
);
const tvSortByOptions = useMemo(
- (): TVFilterOption[] =>
+ (): TVOptionItem[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
@@ -970,7 +742,7 @@ const Page = () => {
);
const tvSortOrderOptions = useMemo(
- (): TVFilterOption[] =>
+ (): TVOptionItem[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
@@ -980,7 +752,7 @@ const Page = () => {
);
const tvFilterByOptions = useMemo(
- (): TVFilterOption[] => [
+ (): TVOptionItem[] => [
{
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 (
-
- {/* Background content - disabled when modal is open */}
- {
+ // 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 */}
+
- {/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
-
- {hasActiveFilters && (
-
- )}
+ {hasActiveFilters && (
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
/>
- 0
- ? `${selectedYears.length} selected`
- : t("library.filters.all")
- }
- onPress={() => setOpenFilterModal("year")}
- disabled={isFiltersDisabled}
- hasActiveFilter={selectedYears.length > 0}
- />
- 0
- ? `${selectedTags.length} selected`
- : t("library.filters.all")
- }
- onPress={() => setOpenFilterModal("tags")}
- disabled={isFiltersDisabled}
- hasActiveFilter={selectedTags.length > 0}
- />
- o.key === sortBy[0])?.value || ""}
- onPress={() => setOpenFilterModal("sortBy")}
- disabled={isFiltersDisabled}
- />
- o.key === sortOrder[0])?.value || ""
- }
- onPress={() => setOpenFilterModal("sortOrder")}
- disabled={isFiltersDisabled}
- />
- 0
- ? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
- : t("library.filters.all")
- }
- onPress={() => setOpenFilterModal("filterBy")}
- disabled={isFiltersDisabled}
- hasActiveFilter={filterBy.length > 0}
- />
-
-
- {/* Grid - using FlatList instead of FlashList to fix focus issues */}
-
-
- {t("library.no_results")}
-
-
+ )}
+ 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={() => (
-
- )}
+ onPress={handleShowGenreFilter}
+ hasTVPreferredFocus={!hasActiveFilters}
+ hasActiveFilter={selectedGenres.length > 0}
+ />
+ 0
+ ? `${selectedYears.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowYearFilter}
+ hasActiveFilter={selectedYears.length > 0}
+ />
+ 0
+ ? `${selectedTags.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowTagFilter}
+ hasActiveFilter={selectedTags.length > 0}
+ />
+ o.key === sortBy[0])?.value || ""}
+ onPress={handleShowSortByFilter}
+ />
+ o.key === sortOrder[0])?.value || ""
+ }
+ onPress={handleShowSortOrderFilter}
+ />
+ 0
+ ? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
+ : t("library.filters.all")
+ }
+ onPress={handleShowFilterByFilter}
+ hasActiveFilter={filterBy.length > 0}
/>
- {/* TV Filter Overlays */}
- setOpenFilterModal(null)}
- />
- setOpenFilterModal(null)}
- />
- setOpenFilterModal(null)}
- />
- setSortBy([value])}
- onClose={() => setOpenFilterModal(null)}
- />
- setSortOrder([value])}
- onClose={() => setOpenFilterModal(null)}
- />
- setOpenFilterModal(null)}
- />
-
+ {/* Grid with flexWrap */}
+ {flatData.length === 0 ? (
+
+
+ {t("library.no_results")}
+
+
+ ) : (
+
+ {flatData.map((item) => renderTVItem(item))}
+
+ )}
+
+ {/* Loading indicator */}
+ {isFetching && (
+
+
+
+ )}
+
);
};
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 831d96c1..5c486866 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -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}
diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
index b8a31190..c649bdf6 100644
--- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
+++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
@@ -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 }) => (
-
-
- {item.Name}
-
-
- {item.ProductionYear}
-
-
-);
+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 (
-
-
- {item.Type === "Movie" && }
- {(item.Type === "Series" || item.Type === "Episode") && (
-
- )}
-
-
-
+ 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 (
+
+ {/* Header */}
+
+ {watchlist.description && (
+
+ {watchlist.description}
+
+ )}
+
+
+
+
+ {items?.length ?? 0}{" "}
+ {(items?.length ?? 0) === 1
+ ? t("watchlists.item")
+ : t("watchlists.items")}
+
+
+
+
+
+ {watchlist.isPublic
+ ? t("watchlists.public")
+ : t("watchlists.private")}
+
+
+ {!isOwner && (
+
+ {t("watchlists.by_owner")}
+
+ )}
+
+
+
+ {/* Grid with flexWrap */}
+ {!items || items.length === 0 ? (
+
+
+
+ {t("watchlists.empty_watchlist")}
+
+
+ ) : (
+
+ {items.map((item, index) => renderTVItem(item, index))}
+
+ )}
+
+ );
+ }
+
+ // Mobile layout with FlashList
return (
}
- renderItem={Platform.isTV ? renderTVItem : renderItem}
+ renderItem={renderItem}
ItemSeparatorComponent={() => (
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index 13c88a92..7caeba24 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -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(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;
}
const [stream, setStream] = useState(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 = {};
+ 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/missing‐data
if (itemStatus.isError || streamStatus.isError) {
return (
@@ -1180,6 +1264,7 @@ export default function page() {
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
+ downloadedFiles={downloadedFiles}
/>
) : (
- {t("jellyseerr.advanced")}
- {modalState.title}
+
+ {t("jellyseerr.advanced")}
+
+
+ {modalState.title}
+
{isDataLoaded && isReady ? (
-
+
{t("jellyseerr.request_button")}
@@ -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",
},
diff --git a/app/(auth)/tv-season-select-modal.tsx b/app/(auth)/tv-season-select-modal.tsx
index 09b46cc5..b9285e65 100644
--- a/app/(auth)/tv-season-select-modal.tsx
+++ b/app/(auth)/tv-season-select-modal.tsx
@@ -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 = ({
};
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}
>
- {t("jellyseerr.select_seasons")}
- {modalState.title}
+
+ {t("jellyseerr.select_seasons")}
+
+
+ {modalState.title}
+
{/* Season cards horizontal scroll */}
-
+
{t("jellyseerr.request_selected")}
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
@@ -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",
},
diff --git a/app/(auth)/tv-series-season-modal.tsx b/app/(auth)/tv-series-season-modal.tsx
index 05b9ca8c..b1117e6f 100644
--- a/app/(auth)/tv-series-season-modal.tsx
+++ b/app/(auth)/tv-series-season-modal.tsx
@@ -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}
>
- {t("item_card.select_season")}
+
+ {t("item_card.select_season")}
+
{isReady && (
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() {
`${v.toFixed(1)}x`}
onChange={(newValue) => {
diff --git a/app/(auth)/tv-user-switch-modal.tsx b/app/(auth)/tv-user-switch-modal.tsx
new file mode 100644
index 00000000..1478b0f7
--- /dev/null
+++ b/app/(auth)/tv-user-switch-modal.tsx
@@ -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(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 (
+
+
+
+
+
+ {t("home.settings.switch_user.title")}
+
+ {modalState.serverName}
+ {isReady && (
+
+ {modalState.accounts.map((account, index) => {
+ const isCurrent = account.userId === modalState.currentUserId;
+ return (
+ handleSelect(account)}
+ />
+ );
+ })}
+
+ )}
+
+
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 4599bcc4..ac900896 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -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() {
}}
>
-
-
-
-
-
-
-
-
-
-
-
-
-
- null,
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ null,
+ }}
+ />
+ null,
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
- null,
- }}
- />
-
-
-
-
-
-
-
-
-
-
- {!Platform.isTV && }
-
-
-
-
-
-
-
-
-
-
-
+ {!Platform.isTV && }
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/app/tv-account-action-modal.tsx b/app/tv-account-action-modal.tsx
new file mode 100644
index 00000000..87f42dfe
--- /dev/null
+++ b/app/tv-account-action-modal.tsx
@@ -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 (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+
+
+ {label}
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+ {/* Account username as title */}
+
+ {modalState.account.username}
+
+
+ {/* Server name as subtitle */}
+
+ {modalState.server.name || modalState.server.address}
+
+
+ {/* Horizontal options */}
+ {isReady && (
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/tv-account-select-modal.tsx b/app/tv-account-select-modal.tsx
new file mode 100644
index 00000000..a8a8e6bd
--- /dev/null
+++ b/app/tv-account-select-modal.tsx
@@ -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 (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ >
+
+
+
+ {label}
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+ {/* Title */}
+
+ {t("server.select_account")}
+
+
+ {/* Server name as subtitle */}
+
+ {modalState.server.name || modalState.server.address}
+
+
+ {/* All options in single horizontal row */}
+ {isReady && (
+
+ {modalState.server.accounts?.map((account, index) => (
+ {
+ modalState.onAccountAction(account);
+ }}
+ hasTVPreferredFocus={index === 0}
+ />
+ ))}
+ {
+ modalState.onAddAccount();
+ router.back();
+ }}
+ />
+ {
+ modalState.onDeleteServer();
+ router.back();
+ }}
+ />
+
+ )}
+
+
+
+
+ );
+}
diff --git a/bun.lock b/bun.lock
index 27360de8..7abad3e1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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=="],
}
}
diff --git a/components/Badge.tsx b/components/Badge.tsx
index 0cb76a74..4c3bcc82 100644
--- a/components/Badge.tsx
+++ b/components/Badge.tsx
@@ -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 = ({
variant = "purple",
...props
}) => {
+ const typography = useScaledTVTypography();
+
const content = (
{iconLeft && {iconLeft}}
@@ -69,7 +71,7 @@ export const Badge: React.FC = ({
{iconLeft && {iconLeft}}
diff --git a/components/Button.tsx b/components/Button.tsx
index fd41fe6d..3b5d6351 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -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> = ({
> = ({
}}
>
-
+
{children}
diff --git a/components/ContinueWatchingPoster.tv.tsx b/components/ContinueWatchingPoster.tv.tsx
deleted file mode 100644
index 7543e49a..00000000
--- a/components/ContinueWatchingPoster.tv.tsx
+++ /dev/null
@@ -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 = ({
- 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 (
-
- );
- }
-
- return (
-
-
-
- {showPlayButton && (
-
-
-
- )}
-
-
-
-
- );
-};
-
-export default ContinueWatchingPoster;
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index e50b4efc..0923dfec 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -73,12 +73,16 @@ export const DownloadItems: React.FC = ({
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,
diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx
index bc83eafa..29f1cb30 100644
--- a/components/GenreTags.tsx
+++ b/components/GenreTags.tsx
@@ -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;
} & 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 (
@@ -60,7 +63,7 @@ export const Tag: React.FC<
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
-
+
{text}
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index 5217e541..4869a75e 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -75,12 +75,20 @@ const ItemContentMobile: React.FC = ({
>(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),
diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx
index 999d6037..02498d37 100644
--- a/components/ItemContent.tv.tsx
+++ b/components/ItemContent.tv.tsx
@@ -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 = 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(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 = 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 = React.memo(
const { showSubtitleModal } = useTVSubtitleModal();
// State for first actor card ref (used for focus guide)
- const [firstActorCardRef, setFirstActorCardRef] = useState(
- null,
- );
-
- // State for last option button ref (used for upward focus guide from cast)
- const [lastOptionButtonRef, setLastOptionButtonRef] = useState(
+ const [_firstActorCardRef, setFirstActorCardRef] = useState(
null,
);
@@ -165,9 +245,16 @@ export const ItemContentTV: React.FC = 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 = 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 = 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
{/* Additional info section */}
- {/* Cast & Crew (text version) */}
+ {/* Season Episodes - Episode only */}
+ {item.Type === "Episode" && seasonEpisodes.length > 1 && (
+
+
+ {t("item_card.more_from_this_season")}
+
+
+
+
+ )}
+
+ {/* From this Series - Episode only */}
+
+
+ {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
+ {showVisualCast && (
+
+ )}
+
+ {/* Cast & Crew (text version - director, etc.) */}
= React.memo(
mediaStreams={selectedOptions.mediaSource.MediaStreams}
/>
)}
-
- {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
- {showVisualCast && (
-
- )}
-
- {/* From this Series - Episode only */}
-
diff --git a/components/ItemContentSkeleton.tv.tsx b/components/ItemContentSkeleton.tv.tsx
index 6b106937..e8186434 100644
--- a/components/ItemContentSkeleton.tv.tsx
+++ b/components/ItemContentSkeleton.tv.tsx
@@ -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 (
- {/* Left side - Poster placeholder */}
-
+ {/* Left side - Content placeholders */}
+
+ {/* Logo placeholder */}
-
-
- {/* Right side - Content placeholders */}
-
- {/* Logo/Title placeholder */}
- {
}}
/>
+
+ {/* Right side - Poster placeholder */}
+
+
+
);
};
diff --git a/components/PasswordEntryModal.tsx b/components/PasswordEntryModal.tsx
index 63b4efe6..efd1cc49 100644
--- a/components/PasswordEntryModal.tsx
+++ b/components/PasswordEntryModal.tsx
@@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC = ({
{/* Password Input */}
- {t("login.password")}
+ {t("login.password_placeholder")}
= ({
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 = ({
{isLoading ? (
) : (
- t("login.login")
+ t("common.login")
)}
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index 1c3fd46f..5b70ac16 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -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";
diff --git a/components/PreviousServersList.tsx b/components/PreviousServersList.tsx
index 008e1be2..251a6ca3 100644
--- a/components/PreviousServersList.tsx
+++ b/components/PreviousServersList.tsx
@@ -73,10 +73,19 @@ export const PreviousServersList: React.FC = ({
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 = ({
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"),
diff --git a/components/apple-tv-carousel/AppleTVCarousel.tsx b/components/apple-tv-carousel/AppleTVCarousel.tsx
deleted file mode 100644
index 82c0f7b4..00000000
--- a/components/apple-tv-carousel/AppleTVCarousel.tsx
+++ /dev/null
@@ -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;
-}
-
-// 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 (
- onPress(index)}
- style={{
- padding: DOT_PADDING, // Increase touch area
- }}
- >
-
-
- );
-};
-
-export const AppleTVCarousel: React.FC = ({
- 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();
- 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 (
-
- {items.map((_, index) => (
-
- ))}
-
- );
- };
-
- const renderSkeletonLoader = () => {
- return (
-
- {/* Background Skeleton */}
-
-
- {/* Dark Overlay Skeleton */}
-
-
- {/* Gradient Fade to Black Top Skeleton */}
-
-
- {/* Gradient Fade to Black Bottom Skeleton */}
-
-
- {/* Logo Skeleton */}
-
-
-
-
- {/* Type and Genres Skeleton */}
-
-
-
-
- {/* Overview Skeleton */}
-
-
-
-
-
- {/* Controls Skeleton */}
-
- {/* Play Button Skeleton */}
-
-
- {/* Played Status Skeleton */}
-
-
-
- {/* Dots Skeleton */}
-
- {[1, 2, 3].map((_, index) => (
-
- ))}
-
-
- );
- };
-
- const renderItem = (item: BaseItemDto, _index: number) => {
- const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
-
- return (
-
- {/* Background Backdrop */}
-
-
-
-
- {/* Dark Overlay */}
-
-
- {/* Gradient Fade to Black at Top */}
-
-
- {/* Gradient Fade to Black at Bottom */}
-
-
- {/* Logo Section */}
- {itemLogoUrl && (
- navigateToItem(item)}
- style={{
- position: "absolute",
- bottom: LOGO_BOTTOM_POSITION,
- left: 0,
- right: 0,
- paddingHorizontal: HORIZONTAL_PADDING,
- alignItems: "center",
- }}
- >
-
-
- )}
-
- {/* Type and Genres Section */}
-
- navigateToItem(item)}>
-
- {(() => {
- 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 "";
- }
- })()}
-
-
-
-
- {/* Overview Section - for Episodes and Movies */}
- {(item.Type === "Episode" || item.Type === "Movie") &&
- item.Overview && (
-
- navigateToItem(item)}>
-
- {item.Overview}
-
-
-
- )}
-
- {/* Controls Section */}
-
-
- {/* Play Button */}
-
- {selectedOptions && (
-
- )}
-
-
- {/* Mark as Played */}
-
-
-
-
- );
- };
-
- // Handle loading state
- if (isLoading) {
- return (
-
- {renderSkeletonLoader()}
-
- );
- }
-
- // Handle empty items
- if (!hasItems) {
- return null;
- }
-
- return (
-
-
-
- {items.map((item, index) => renderItem(item, index))}
-
-
-
- {/* Animated Dots Indicator */}
- {renderDots()}
-
- );
-};
diff --git a/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx b/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
deleted file mode 100644
index ea9bd98d..00000000
--- a/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
+++ /dev/null
@@ -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 (
-
-
-
- );
-
- return (
-
- onToggle(isPlayed)}
- />
-
- );
-};
diff --git a/components/common/Text.tsx b/components/common/Text.tsx
index 6c6fee71..739177d7 100644
--- a/components/common/Text.tsx
+++ b/components/common/Text.tsx
@@ -4,7 +4,7 @@ export function Text(props: TextProps) {
if (Platform.isTV)
return (
diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx
index 2c85e094..cc40d2dc 100644
--- a/components/common/TouchableItemRouter.tsx
+++ b/components/common/TouchableItemRouter.tsx
@@ -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! },
diff --git a/components/home/Favorites.tv.tsx b/components/home/Favorites.tv.tsx
index a4aab9b5..b76a7fe8 100644
--- a/components/home/Favorites.tv.tsx
+++ b/components/home/Favorites.tv.tsx
@@ -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;
export const Favorites = () => {
+ const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
@@ -148,7 +149,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,
}}
>
diff --git a/components/home/Home.tsx b/components/home/Home.tsx
index abd53e0f..74e7c8b0 100644
--- a/components/home/Home.tsx
+++ b/components/home/Home.tsx
@@ -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 ||
diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx
index d509f095..dbeccca2 100644
--- a/components/home/Home.tv.tsx
+++ b/components/home/Home.tv.tsx
@@ -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;
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>(new Set());
+ const { showItemActions } = useTVItemActionModal();
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState(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();
+ 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 = () => {
>
{
style={{
textAlign: "center",
opacity: 0.7,
- fontSize: TVTypography.body,
+ fontSize: typography.body,
color: "#FFFFFF",
}}
>
@@ -579,7 +626,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 (
- {/* Dynamic backdrop with crossfade */}
-
- {/* Layer 0 */}
-
- {layer0Url && (
-
- )}
-
- {/* Layer 1 */}
-
- {layer1Url && (
-
- )}
-
- {/* Gradient overlays for readability */}
-
-
+ >
+ {/* Layer 0 */}
+
+ {layer0Url && (
+
+ )}
+
+ {/* Layer 1 */}
+
+ {layer1Url && (
+
+ )}
+
+ {/* Gradient overlays for readability */}
+
+
+ )}
-
- {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 && (
+
+ )}
+
+
+ {/* 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 && (
)}
@@ -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 (
{
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
- enabled={isHighPriority || allHighPriorityLoaded}
- onLoaded={
- isHighPriority
- ? () => markSectionLoaded(section.queryKey)
- : undefined
- }
isFirstSection={isFirstSection}
onItemFocus={handleItemFocus}
parentId={section.parentId}
diff --git a/components/home/HomeWithCarousel.tsx b/components/home/HomeWithCarousel.tsx
deleted file mode 100644
index c513294f..00000000
--- a/components/home/HomeWithCarousel.tsx
+++ /dev/null
@@ -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;
- orientation?: "horizontal" | "vertical";
- pageSize?: number;
-};
-
-type MediaListSectionType = {
- type: "MediaListSection";
- queryKey: (string | undefined)[];
- queryFn: QueryFunction;
-};
-
-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();
- const scrollOffset = useScrollViewOffset(animatedScrollRef);
- const { downloadedItems, cleanCacheDirectory } = useDownload();
- const prevIsConnected = useRef(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: () => (
- {
- router.push("/(auth)/downloads");
- }}
- className='ml-1.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
- });
- }, [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();
- 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(
- 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 (
-
- {title}
- {subtitle}
-
-
- {!Platform.isTV && (
-
- )}
-
-
- )
- }
- >
- {retryLoading ? (
-
- ) : (
- t("home.retry")
- )}
-
-
-
- );
- }
-
- if (e1)
- return (
-
- {t("home.oops")}
-
- {t("home.error_message")}
-
-
- );
-
- if (l1)
- return (
-
-
-
- );
-
- return (
- {
- setScrollY(event.nativeEvent.contentOffset.y);
- }}
- >
-
-
-
- {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 && (
-
- )}
- {settings.streamyStatsSeriesRecommendations && (
-
- )}
- {settings.streamyStatsPromotedWatchlists && (
-
- )}
- >
- ) : null;
-
- if (section.type === "InfiniteScrollingCollectionList") {
- return (
-
-
- {streamystatsSections}
-
- );
- }
- if (section.type === "MediaListSection") {
- return (
-
-
- {streamystatsSections}
-
- );
- }
- return null;
- })}
-
-
-
-
- );
-};
diff --git a/components/home/InfiniteScrollingCollectionList.tsx b/components/home/InfiniteScrollingCollectionList.tsx
index e5e5b7a6..de4c6462 100644
--- a/components/home/InfiniteScrollingCollectionList.tsx
+++ b/components/home/InfiniteScrollingCollectionList.tsx
@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({
},
initialPageParam: 0,
staleTime: 60 * 1000, // 1 minute
- refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx
index 4b85c441..ce32656d 100644
--- a/components/home/InfiniteScrollingCollectionList.tv.tsx
+++ b/components/home/InfiniteScrollingCollectionList.tv.tsx
@@ -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 (
-
- {item.Type === "Episode" ? (
- <>
-
- {item.Name}
-
-
- {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
- {" - "}
- {item.SeriesName}
-
- >
- ) : (
- <>
-
- {item.Name}
-
-
- {item.ProductionYear}
-
- >
- )}
-
- );
-};
+type Typography = ReturnType;
+type PosterSizes = ReturnType;
// 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<{
/>
= ({
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>(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 = ({
}, [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 = ({
} as any);
}, [router, parentId]);
- const getItemLayout = useCallback(
- (_data: ArrayLike | 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 ;
- }
- if (item.Type === "Episode" && !isHorizontal) {
- return ;
- }
- if (item.Type === "Movie" && isHorizontal) {
- return ;
- }
- if (item.Type === "Movie" && !isHorizontal) {
- return ;
- }
- if (item.Type === "Series" && !isHorizontal) {
- return ;
- }
- if (item.Type === "Series" && isHorizontal) {
- return ;
- }
- if (item.Type === "Program") {
- return ;
- }
- if (item.Type === "BoxSet" && !isHorizontal) {
- return ;
- }
- if (item.Type === "BoxSet" && isHorizontal) {
- return ;
- }
- if (item.Type === "Playlist" && !isHorizontal) {
- return ;
- }
- if (item.Type === "Playlist" && isHorizontal) {
- return ;
- }
- if (item.Type === "Video" && !isHorizontal) {
- return ;
- }
- if (item.Type === "Video" && isHorizontal) {
- return ;
- }
- // Default fallback
- return isHorizontal ? (
-
- ) : (
-
- );
- };
return (
-
-
+ handleItemPress(item)}
+ onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
- onBlur={handleItemBlur}
- >
- {renderPoster()}
-
-
+ width={itemWidth}
+ />
);
},
@@ -352,8 +233,9 @@ export const InfiniteScrollingCollectionList: React.FC = ({
isFirstSection,
itemWidth,
handleItemPress,
+ showItemActions,
handleItemFocus,
- handleItemBlur,
+ ITEM_GAP,
],
);
@@ -365,11 +247,12 @@ export const InfiniteScrollingCollectionList: React.FC = ({
{/* Section Header */}
{title}
@@ -379,8 +262,8 @@ export const InfiniteScrollingCollectionList: React.FC = ({
{t("home.no_items")}
@@ -420,7 +303,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
- fontSize: TVTypography.callout,
+ fontSize: typography.callout,
}}
numberOfLines={1}
>
@@ -444,12 +327,15 @@ export const InfiniteScrollingCollectionList: React.FC = ({
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={
= ({
onPress={handleSeeAllPress}
orientation={orientation}
disabled={disabled}
- onFocus={handleSeeAllFocus}
- onBlur={handleItemBlur}
+ typography={typography}
+ posterSizes={posterSizes}
/>
)}
diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx
index 3d64466f..185ad8c2 100644
--- a/components/home/ScrollingCollectionList.tsx
+++ b/components/home/ScrollingCollectionList.tsx
@@ -44,7 +44,6 @@ export const ScrollingCollectionList: React.FC = ({
queryKey: queryKey,
queryFn,
staleTime: 60 * 1000, // 1 minute
- refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled: enableLazyLoading ? isInView : true,
diff --git a/components/home/StreamystatsPromotedWatchlists.tsx b/components/home/StreamystatsPromotedWatchlists.tsx
index 81d50675..9a4f41d3 100644
--- a/components/home/StreamystatsPromotedWatchlists.tsx
+++ b/components/home/StreamystatsPromotedWatchlists.tsx
@@ -80,7 +80,6 @@ const WatchlistSection: React.FC = ({
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,
});
diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx
index 7bd2320c..f2ae66a8 100644
--- a/components/home/StreamystatsPromotedWatchlists.tv.tsx
+++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx
@@ -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 (
-
-
- {item.Name}
-
-
- {item.ProductionYear}
-
-
- );
-};
-
interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist;
jellyfinServerId: string;
@@ -60,10 +36,15 @@ const WatchlistSection: React.FC = ({
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 = ({
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 = ({
const getItemLayout = useCallback(
(_data: ArrayLike | 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 (
-
-
+ handleItemPress(item)}
+ onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
- hasTVPreferredFocus={false}
- >
- {item.Type === "Movie" && }
- {item.Type === "Series" && }
-
-
+ width={posterSizes.poster}
+ />
);
},
- [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 = ({
{watchlist.name}
@@ -175,11 +162,11 @@ const WatchlistSection: React.FC = ({
}}
>
{[1, 2, 3, 4, 5].map((i) => (
-
+
= ({
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) => (
-
+
= ({
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000, // 5 minutes
- refetchOnMount: false,
refetchOnWindowFocus: false,
});
@@ -136,7 +135,6 @@ export const StreamystatsRecommendations: React.FC = ({
enabled:
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
- refetchOnMount: false,
refetchOnWindowFocus: false,
});
diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx
index bec6a490..a35da259 100644
--- a/components/home/StreamystatsRecommendations.tv.tsx
+++ b/components/home/StreamystatsRecommendations.tv.tsx
@@ -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 (
-
-
- {item.Name}
-
-
- {item.ProductionYear}
-
-
- );
-};
-
export const StreamystatsRecommendations: React.FC = ({
title,
type,
@@ -64,10 +37,13 @@ export const StreamystatsRecommendations: React.FC = ({
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 = ({
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 = ({
},
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 = ({
const getItemLayout = useCallback(
(_data: ArrayLike | 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 (
-
-
+ handleItemPress(item)}
+ onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
- hasTVPreferredFocus={false}
- >
- {item.Type === "Movie" && }
- {item.Type === "Series" && }
-
-
+ width={sizes.posters.poster}
+ />
);
},
- [handleItemPress, onItemFocus],
+ [sizes, handleItemPress, showItemActions, onItemFocus],
);
if (!streamyStatsEnabled) return null;
@@ -218,11 +193,12 @@ export const StreamystatsRecommendations: React.FC = ({
{title}
@@ -232,17 +208,17 @@ export const StreamystatsRecommendations: React.FC = ({
{[1, 2, 3, 4, 5].map((i) => (
-
+
= ({
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,
}}
/>
)}
diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx
new file mode 100644
index 00000000..0d5b6bac
--- /dev/null
+++ b/components/home/TVHeroCarousel.tsx
@@ -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 = 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 (
+
+
+
+ );
+ }
+
+ // Fallback for non-tvOS or older tvOS
+ return (
+
+
+ {posterUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+ },
+);
+
+// Debounce delay to prevent rapid backdrop changes when navigating fast
+const BACKDROP_DEBOUNCE_MS = 300;
+
+export const TVHeroCarousel: React.FC = ({
+ 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(
+ items[0] || null,
+ );
+ const debounceTimerRef = useRef | 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(null);
+ const [layer1Url, setLayer1Url] = useState(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 }) => (
+
+ ),
+ [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 (
+
+ {/* Backdrop layers with crossfade */}
+
+ {/* Layer 0 */}
+
+ {layer0Url && (
+
+ )}
+
+ {/* Layer 1 */}
+
+ {layer1Url && (
+
+ )}
+
+
+ {/* Gradient overlays */}
+
+
+ {/* Horizontal gradient for left side text contrast */}
+
+
+
+ {/* Content overlay - text elements with padding */}
+
+ {/* Logo or Title */}
+ {logoUrl ? (
+
+ ) : (
+
+ {displayTitle}
+
+ )}
+
+ {/* Episode subtitle */}
+ {episodeSubtitle && (
+
+ {episodeSubtitle}
+
+ )}
+
+ {/* Description */}
+ {activeItem?.Overview && (
+
+ {activeItem.Overview}
+
+ )}
+
+ {/* Metadata badges */}
+
+ {year && (
+
+ {year}
+
+ )}
+ {duration && (
+
+ {duration}
+
+ )}
+ {activeItem?.OfficialRating && (
+
+
+ {activeItem.OfficialRating}
+
+
+ )}
+ {hasProgress && (
+
+
+
+
+
+ {Math.round(playedPercent)}%
+
+
+ )}
+
+
+
+ {/* Thumbnail carousel - edge-to-edge */}
+
+
+
+
+ );
+};
diff --git a/components/jellyseerr/discover/TVDiscoverSlide.tsx b/components/jellyseerr/discover/TVDiscoverSlide.tsx
index 6d750fea..5f98fcf5 100644
--- a/components/jellyseerr/discover/TVDiscoverSlide.tsx
+++ b/components/jellyseerr/discover/TVDiscoverSlide.tsx
@@ -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 = ({
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 = ({
= ({
{year && (
= ({
slide,
isFirstSlide = false,
}) => {
+ const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -232,7 +234,7 @@ export const TVDiscoverSlide: React.FC = ({
= ({
onPress,
refSetter,
}) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
@@ -128,7 +129,7 @@ const TVCastCard: React.FC = ({
= ({
};
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 */}
{
{/* Year */}
{
>
{
/>
{
/>
{
/>
{
/>
{
{
/>
{
/>
{
= ({
onClose,
onRequested,
}) => {
+ const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
@@ -389,7 +390,7 @@ export const TVRequestModal: React.FC = ({
>
= ({
= ({
/>
= ({
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 = ({
>
@@ -65,7 +66,7 @@ export const TVRequestOptionRow: React.FC = ({
= ({
onToggle,
disabled = false,
}) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.08,
@@ -57,7 +58,7 @@ const TVToggleChip: React.FC = ({
>
= ({
onToggle,
disabled = false,
}) => {
+ const typography = useScaledTVTypography();
if (items.length === 0) return null;
return (
= ({ 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<{
{
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",
}}
>
-
+
{t("library.no_libraries_found")}
diff --git a/components/library/TVLibraryCard.tsx b/components/library/TVLibraryCard.tsx
index 70918762..ef607b74 100644
--- a/components/library/TVLibraryCard.tsx
+++ b/components/library/TVLibraryCard.tsx
@@ -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 = ({ 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 = ({ library }) => {
= ({ library }) => {
{itemsCount !== undefined && (
void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+const CARD_WIDTH = 200;
+const CARD_HEIGHT = 160;
+
+export const TVChannelCard: React.FC = ({
+ 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 (
+
+
+ {/* Channel logo or number */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+ {channel.ChannelNumber || "?"}
+
+
+ )}
+
+
+ {/* Channel name */}
+
+ {channel.Name}
+
+
+ {/* Channel number (if name is shown) */}
+ {channel.ChannelNumber && (
+
+ Ch. {channel.ChannelNumber}
+
+ )}
+
+
+ );
+};
+
+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 };
diff --git a/components/livetv/TVChannelsGrid.tsx b/components/livetv/TVChannelsGrid.tsx
new file mode 100644
index 00000000..f93beb35
--- /dev/null
+++ b/components/livetv/TVChannelsGrid.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (channels.length === 0) {
+ return (
+
+
+ {t("live_tv.no_channels")}
+
+
+ );
+ }
+
+ return (
+
+
+ {channels.map((channel, index) => (
+ handleChannelPress(channel.Id)}
+ // No hasTVPreferredFocus - tab buttons handle initial focus
+ />
+ ))}
+
+
+ );
+};
+
+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)",
+ },
+});
diff --git a/components/livetv/TVGuideChannelRow.tsx b/components/livetv/TVGuideChannelRow.tsx
new file mode 100644
index 00000000..73ba7491
--- /dev/null
+++ b/components/livetv/TVGuideChannelRow.tsx
@@ -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 = ({
+ 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 (
+
+ {programCells.map(({ program, width, left }, index) => (
+
+ onProgramPress(program)}
+ disabled={disabled}
+ refSetter={index === 0 ? firstProgramRefSetter : undefined}
+ />
+
+ ))}
+
+ {/* Empty state */}
+ {programCells.length === 0 && (
+
+ {/* Empty row indicator */}
+
+ )}
+
+ );
+};
+
+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,
+ },
+});
diff --git a/components/livetv/TVGuidePageNavigation.tsx b/components/livetv/TVGuidePageNavigation.tsx
new file mode 100644
index 00000000..5188c54e
--- /dev/null
+++ b/components/livetv/TVGuidePageNavigation.tsx
@@ -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 = ({
+ 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 (
+
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export const TVGuidePageNavigation: React.FC = ({
+ currentPage,
+ totalPages,
+ onPrevious,
+ onNext,
+ disabled = false,
+ prevButtonRefSetter,
+}) => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+
+ return (
+
+
+
+
+ = totalPages}
+ disabled={disabled}
+ />
+
+
+
+ {t("live_tv.page_of", { current: currentPage, total: totalPages })}
+
+
+ );
+};
+
+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)",
+ },
+});
diff --git a/components/livetv/TVGuideProgramCell.tsx b/components/livetv/TVGuideProgramCell.tsx
new file mode 100644
index 00000000..e8287132
--- /dev/null
+++ b/components/livetv/TVGuideProgramCell.tsx
@@ -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 = ({
+ 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 (
+
+
+ {/* LIVE badge */}
+ {isCurrentlyAiring && (
+
+
+ LIVE
+
+
+ )}
+
+ {/* Program name */}
+
+ {program.Name}
+
+
+ {/* Time range */}
+
+ {formatTime(program.StartDate)} - {formatTime(program.EndDate)}
+
+
+
+ );
+};
+
+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",
+ },
+});
diff --git a/components/livetv/TVGuideTimeHeader.tsx b/components/livetv/TVGuideTimeHeader.tsx
new file mode 100644
index 00000000..a3ca8348
--- /dev/null
+++ b/components/livetv/TVGuideTimeHeader.tsx
@@ -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 = ({
+ 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 (
+
+ {hours.map((hour, index) => (
+
+
+ {formatHour(hour)}
+
+
+ ))}
+
+ );
+};
+
+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",
+ },
+});
diff --git a/components/livetv/TVLiveTVGuide.tsx b/components/livetv/TVLiveTVGuide.tsx
new file mode 100644
index 00000000..7c1f12f6
--- /dev/null
+++ b/components/livetv/TVLiveTVGuide.tsx
@@ -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;
+}> = ({ channel, typography }) => (
+
+
+ {channel.ChannelNumber}
+
+
+ {channel.Name}
+
+
+);
+
+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(null);
+ const mainVerticalRef = useRef(null);
+
+ // Focus guide refs for bidirectional navigation
+ const [firstProgramRef, setFirstProgramRef] = useState(null);
+ const [prevButtonRef, setPrevButtonRef] = useState(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) => {
+ 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 = {};
+ 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 (
+
+
+
+ );
+ }
+
+ if (paginatedChannels.length === 0) {
+ return (
+
+
+ {t("live_tv.no_programs")}
+
+
+ );
+ }
+
+ return (
+
+ {/* Page Navigation */}
+ {totalPages > 1 && (
+
+
+
+ )}
+
+ {/* Bidirectional focus guides */}
+ {firstProgramRef && (
+
+ )}
+ {prevButtonRef && (
+
+ )}
+
+ {/* Main grid container */}
+
+ {/* Fixed channel column */}
+
+ {/* Spacer for time header */}
+
+
+ {/* Channel labels - synced with main scroll */}
+
+ {paginatedChannels.map((channel, index) => (
+
+ ))}
+
+
+
+ {/* Scrollable programs area */}
+
+
+ {/* Time header */}
+
+
+ {/* Programs grid - vertical scroll */}
+
+ {paginatedChannels.map((channel, index) => {
+ const channelPrograms = channel.Id
+ ? (programsByChannel[channel.Id] ?? [])
+ : [];
+ return (
+
+ );
+ })}
+
+
+ {/* Current time indicator */}
+ {currentTimeOffset > 0 && currentTimeOffset < totalWidth && (
+
+ )}
+
+
+
+
+ );
+};
+
+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",
+ },
+});
diff --git a/components/livetv/TVLiveTVPage.tsx b/components/livetv/TVLiveTVPage.tsx
new file mode 100644
index 00000000..a3f3ed45
--- /dev/null
+++ b/components/livetv/TVLiveTVPage.tsx
@@ -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("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 = () => (
+
+
+ {sections.map((section) => (
+
+ ))}
+
+
+ );
+
+ const renderTabContent = () => {
+ if (activeTab === "programs") {
+ return renderProgramsContent();
+ }
+
+ if (activeTab === "guide") {
+ return ;
+ }
+
+ if (activeTab === "channels") {
+ return ;
+ }
+
+ // Placeholder for other tabs
+ const tab = TABS.find((t) => t.id === activeTab);
+ return ;
+ };
+
+ return (
+
+ {/* Header with Title and Tabs */}
+
+ {/* Title */}
+
+ Live TV
+
+
+ {/* Tab Bar */}
+
+ {TABS.map((tab) => (
+ handleTabSelect(tab.id)}
+ hasTVPreferredFocus={activeTab === tab.id}
+ switchOnFocus={true}
+ />
+ ))}
+
+
+
+ {/* Tab Content */}
+ {renderTabContent()}
+
+ );
+};
diff --git a/components/livetv/TVLiveTVPlaceholder.tsx b/components/livetv/TVLiveTVPlaceholder.tsx
new file mode 100644
index 00000000..2880cbed
--- /dev/null
+++ b/components/livetv/TVLiveTVPlaceholder.tsx
@@ -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 = ({
+ tabName,
+}) => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+
+ return (
+
+
+ {tabName}
+
+
+ {t("live_tv.coming_soon")}
+
+
+ );
+};
diff --git a/components/login/Login.tsx b/components/login/Login.tsx
index 0b1fedc7..7cf5f43f 100644
--- a/components/login/Login.tsx
+++ b/components/login/Login.tsx
@@ -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({
diff --git a/components/login/TVAccountCard.tsx b/components/login/TVAccountCard.tsx
index 2ef2d913..bb78639d 100644
--- a/components/login/TVAccountCard.tsx
+++ b/components/login/TVAccountCard.tsx
@@ -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 = ({
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 = ({
{/* Security Icon */}
-
+
diff --git a/components/login/TVAddIcon.tsx b/components/login/TVAddIcon.tsx
new file mode 100644
index 00000000..111c706a
--- /dev/null
+++ b/components/login/TVAddIcon.tsx
@@ -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(
+ ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
+
+ );
+ },
+);
diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx
new file mode 100644
index 00000000..7d3cefe1
--- /dev/null
+++ b/components/login/TVAddServerForm.tsx
@@ -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;
+ 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 (
+ {
+ setIsFocused(true);
+ animateFocus(true);
+ }}
+ onBlur={() => {
+ setIsFocused(false);
+ animateFocus(false);
+ }}
+ style={{ alignSelf: "flex-start", marginBottom: 24 }}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export const TVAddServerForm: React.FC = ({
+ 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 (
+
+
+ {/* Back Button */}
+
+
+ {/* Server URL Input */}
+
+
+
+
+ {/* Connect Button */}
+
+
+
+
+ {/* Hint text */}
+
+ {t("server.enter_url_to_jellyfin_server")}
+
+
+
+ );
+};
diff --git a/components/login/TVAddUserForm.tsx b/components/login/TVAddUserForm.tsx
new file mode 100644
index 00000000..17f8fe89
--- /dev/null
+++ b/components/login/TVAddUserForm.tsx
@@ -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;
+ onQuickConnect: () => Promise;
+ 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 (
+ {
+ setIsFocused(true);
+ animateFocus(true);
+ }}
+ onBlur={() => {
+ setIsFocused(false);
+ animateFocus(false);
+ }}
+ style={{ alignSelf: "flex-start", marginBottom: 40 }}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export const TVAddUserForm: React.FC = ({
+ 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 (
+
+
+ {/* Back Button */}
+
+
+ {/* Title */}
+
+ {serverName ? (
+ <>
+ {`${t("login.login_to_title")} `}
+ {serverName}
+ >
+ ) : (
+ t("login.login_title")
+ )}
+
+
+ {serverAddress}
+
+
+ {/* Username Input */}
+
+
+ setCredentials((prev) => ({ ...prev, username: text }))
+ }
+ autoCapitalize='none'
+ autoCorrect={false}
+ textContentType='username'
+ returnKeyType='next'
+ hasTVPreferredFocus
+ disabled={isDisabled}
+ />
+
+
+ {/* Password Input */}
+
+
+ setCredentials((prev) => ({ ...prev, password: text }))
+ }
+ secureTextEntry
+ autoCapitalize='none'
+ textContentType='password'
+ returnKeyType='done'
+ disabled={isDisabled}
+ />
+
+
+ {/* Save Account Toggle */}
+
+
+
+
+ {/* Login Button */}
+
+
+
+
+ {/* Quick Connect Button */}
+
+
+
+ );
+};
diff --git a/components/login/TVBackIcon.tsx b/components/login/TVBackIcon.tsx
new file mode 100644
index 00000000..8cbc08d9
--- /dev/null
+++ b/components/login/TVBackIcon.tsx
@@ -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(
+ ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
+
+ );
+ },
+);
diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx
index a575b3e4..2e9435f1 100644
--- a/components/login/TVInput.tsx
+++ b/components/login/TVInput.tsx
@@ -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 = ({
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 (
- {
- setIsFocused(true);
- animateFocus(true);
- }}
- onBlur={() => {
- setIsFocused(false);
- animateFocus(false);
- }}
- style={{ alignSelf: "flex-start", marginBottom: size(40) }}
- disabled={disabled}
- focusable={!disabled}
- >
-
-
-
- {label}
-
-
-
- );
-};
+type TVLoginScreen =
+ | "server-selection"
+ | "user-selection"
+ | "add-server"
+ | "add-user";
export const TVLogin: React.FC = () => {
const api = useAtomValue(apiAtom);
@@ -113,6 +37,7 @@ export const TVLogin: React.FC = () => {
login,
removeServer,
initiateQuickConnect,
+ stopQuickConnectPolling,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
@@ -123,20 +48,33 @@ export const TVLogin: React.FC = () => {
password: _password,
} = params as { apiUrl: string; username: string; password: string };
+ // Selected server persistence
+ const [selectedTVServer, setSelectedTVServer] = useAtom(selectedTVServerAtom);
+ const [_previousServers, setPreviousServers] =
+ useMMKVString("previousServers");
+
+ // Get current servers list
+ const previousServers = useMemo(() => {
+ try {
+ return JSON.parse(_previousServers || "[]") as SavedServer[];
+ } catch {
+ return [];
+ }
+ }, [_previousServers]);
+
+ // Current screen state
+ const [currentScreen, setCurrentScreen] =
+ useState("server-selection");
+
+ // Current selected server for user selection screen
+ const [currentServer, setCurrentServer] = useState(null);
+ const [serverName, setServerName] = useState("");
+
+ // Loading states
const [loadingServerCheck, setLoadingServerCheck] = useState(false);
const [loading, setLoading] = useState(false);
- const [serverURL, setServerURL] = useState(_apiUrl || "");
- const [serverName, setServerName] = useState("");
- const [credentials, setCredentials] = useState<{
- username: string;
- password: string;
- }>({
- username: _username || "",
- password: _password || "",
- });
// Save account state
- const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
@@ -146,41 +84,59 @@ export const TVLogin: React.FC = () => {
// PIN/Password entry for saved accounts
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
- const [selectedServer, setSelectedServer] = useState(
- null,
- );
const [selectedAccount, setSelectedAccount] =
useState(null);
- // Server action sheet state
- const [showServerActionSheet, setShowServerActionSheet] = useState(false);
- const [actionSheetServer, setActionSheetServer] =
- useState(null);
- const [loginTriggerServer, setLoginTriggerServer] =
- useState(null);
- const [actionSheetKey, setActionSheetKey] = useState(0);
-
// Track if any modal is open to disable background focus
const isAnyModalOpen =
- showSaveModal ||
- pinModalVisible ||
- passwordModalVisible ||
- showServerActionSheet;
+ showSaveModal || pinModalVisible || passwordModalVisible;
- // Auto login from URL params
+ // Refresh servers list helper
+ const refreshServers = () => {
+ const servers = getPreviousServers();
+ setPreviousServers(JSON.stringify(servers));
+ };
+
+ // Initialize on mount - check if we have a persisted server
+ useEffect(() => {
+ if (selectedTVServer) {
+ // Find the full server data from previousServers
+ const server = previousServers.find(
+ (s) => s.address === selectedTVServer.address,
+ );
+ if (server) {
+ setCurrentServer(server);
+ setServerName(selectedTVServer.name || "");
+ setCurrentScreen("user-selection");
+ } else {
+ // Server no longer exists, clear persistence
+ setSelectedTVServer(null);
+ }
+ }
+ }, []);
+
+ // Stop Quick Connect polling when leaving the login page
+ useEffect(() => {
+ return () => {
+ stopQuickConnectPolling();
+ };
+ }, [stopQuickConnectPolling]);
+
+ // 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) {
+ login(_username, _password);
+ }
+ }, [api?.basePath, _apiUrl, _username, _password]);
// Update header
useEffect(() => {
@@ -190,179 +146,7 @@ export const TVLogin: React.FC = () => {
});
}, [serverName, navigation]);
- const handleLogin = async () => {
- const result = CredentialsSchema.safeParse(credentials);
- if (!result.success) return;
-
- if (saveAccount) {
- setPendingLogin({
- username: credentials.username,
- password: credentials.password,
- });
- setShowSaveModal(true);
- } else {
- await performLogin(credentials.username, credentials.password);
- }
- };
-
- const performLogin = async (
- username: string,
- password: string,
- options?: {
- saveAccount?: boolean;
- securityType?: AccountSecurityType;
- pinCode?: string;
- },
- ) => {
- setLoading(true);
- try {
- await login(username, password, serverName, options);
- } catch (error) {
- if (error instanceof Error) {
- Alert.alert(t("login.connection_failed"), error.message);
- } else {
- Alert.alert(
- t("login.connection_failed"),
- t("login.an_unexpected_error_occured"),
- );
- }
- } finally {
- setLoading(false);
- setPendingLogin(null);
- }
- };
-
- const handleSaveAccountConfirm = async (
- securityType: AccountSecurityType,
- pinCode?: string,
- ) => {
- setShowSaveModal(false);
- if (pendingLogin) {
- await performLogin(pendingLogin.username, pendingLogin.password, {
- saveAccount: true,
- securityType,
- pinCode,
- });
- }
- };
-
- const handleQuickLoginWithSavedCredential = async (
- serverUrl: string,
- userId: string,
- ) => {
- await loginWithSavedCredential(serverUrl, userId);
- };
-
- const handlePasswordLogin = async (
- serverUrl: string,
- username: string,
- password: string,
- ) => {
- await loginWithPassword(serverUrl, username, password);
- };
-
- const handleAddAccount = (server: SavedServer) => {
- setServer({ address: server.address });
- if (server.name) {
- setServerName(server.name);
- }
- };
-
- const handlePinRequired = (
- server: SavedServer,
- account: SavedServerAccount,
- ) => {
- setSelectedServer(server);
- setSelectedAccount(account);
- setPinModalVisible(true);
- };
-
- const handlePasswordRequired = (
- server: SavedServer,
- account: SavedServerAccount,
- ) => {
- setSelectedServer(server);
- setSelectedAccount(account);
- setPasswordModalVisible(true);
- };
-
- const handlePinSuccess = async () => {
- setPinModalVisible(false);
- if (selectedServer && selectedAccount) {
- await handleQuickLoginWithSavedCredential(
- selectedServer.address,
- selectedAccount.userId,
- );
- }
- setSelectedServer(null);
- setSelectedAccount(null);
- };
-
- const handlePasswordSubmit = async (password: string) => {
- if (selectedServer && selectedAccount) {
- await handlePasswordLogin(
- selectedServer.address,
- selectedAccount.username,
- password,
- );
- }
- setPasswordModalVisible(false);
- setSelectedServer(null);
- setSelectedAccount(null);
- };
-
- const handleForgotPIN = async () => {
- if (selectedServer) {
- setSelectedServer(null);
- setSelectedAccount(null);
- setPinModalVisible(false);
- }
- };
-
- // Server action sheet handlers
- const handleServerAction = (server: SavedServer) => {
- setActionSheetServer(server);
- setActionSheetKey((k) => k + 1); // Force remount to reset focus
- setShowServerActionSheet(true);
- };
-
- const handleServerActionLogin = () => {
- setShowServerActionSheet(false);
- if (actionSheetServer) {
- // Trigger the login flow in TVPreviousServersList
- setLoginTriggerServer(actionSheetServer);
- // Reset the trigger after a tick to allow re-triggering the same server
- setTimeout(() => setLoginTriggerServer(null), 0);
- }
- };
-
- const handleServerActionDelete = () => {
- if (!actionSheetServer) return;
-
- Alert.alert(
- t("server.remove_server"),
- t("server.remove_server_description", {
- server: actionSheetServer.name || actionSheetServer.address,
- }),
- [
- {
- text: t("common.cancel"),
- style: "cancel",
- onPress: () => setShowServerActionSheet(false),
- },
- {
- text: t("common.delete"),
- style: "destructive",
- onPress: async () => {
- await removeServerFromList(actionSheetServer.address);
- setShowServerActionSheet(false);
- setActionSheetServer(null);
- },
- },
- ],
- );
- };
-
+ // Server URL checking
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
@@ -410,27 +194,246 @@ export const TVLogin: React.FC = () => {
return undefined;
}
- const handleConnect = useCallback(async (url: string) => {
- url = url.trim().replace(/\/$/, "");
- console.log("[TVLogin] handleConnect called with:", url);
- try {
- const result = await checkUrl(url);
- console.log("[TVLogin] checkUrl result:", result);
- if (result === undefined) {
+ // Handle connecting to a new server
+ const handleConnect = useCallback(
+ async (url: string) => {
+ url = url.trim().replace(/\/$/, "");
+ try {
+ const result = await checkUrl(url);
+ if (result === undefined) {
+ Alert.alert(
+ t("login.connection_failed"),
+ t("login.could_not_connect_to_server"),
+ );
+ return;
+ }
+ await setServer({ address: result });
+
+ // Update server list and get the new server data
+ refreshServers();
+
+ // Find or create server entry
+ const servers = getPreviousServers();
+ const server = servers.find((s) => s.address === result);
+
+ if (server) {
+ setCurrentServer(server);
+ setSelectedTVServer({ address: result, name: serverName });
+ setCurrentScreen("user-selection");
+ }
+ } catch (error) {
+ console.error("[TVLogin] Error in handleConnect:", error);
+ }
+ },
+ [checkUrl, setServer, serverName, setSelectedTVServer],
+ );
+
+ // Handle selecting an existing server
+ const handleServerSelect = (server: SavedServer) => {
+ setCurrentServer(server);
+ setServerName(server.name || "");
+ setSelectedTVServer({ address: server.address, name: server.name });
+ setCurrentScreen("user-selection");
+ };
+
+ // Handle changing server (back from user selection)
+ const handleChangeServer = () => {
+ setSelectedTVServer(null);
+ setCurrentServer(null);
+ setServerName("");
+ removeServer();
+ setCurrentScreen("server-selection");
+ };
+
+ // Handle deleting a server
+ const handleDeleteServer = async (server: SavedServer) => {
+ await removeServerFromList(server.address);
+ refreshServers();
+ // If we deleted the currently selected server, clear it
+ if (selectedTVServer?.address === server.address) {
+ setSelectedTVServer(null);
+ setCurrentServer(null);
+ }
+ };
+
+ // Handle user selection
+ const handleUserSelect = async (account: SavedServerAccount) => {
+ if (!currentServer) return;
+
+ switch (account.securityType) {
+ case "none":
+ setLoading(true);
+ 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,
+ [
+ {
+ text: t("common.ok"),
+ onPress: () => setCurrentScreen("add-user"),
+ },
+ ],
+ );
+ } finally {
+ setLoading(false);
+ }
+ break;
+
+ case "pin":
+ setSelectedAccount(account);
+ setPinModalVisible(true);
+ break;
+
+ case "password":
+ setSelectedAccount(account);
+ setPasswordModalVisible(true);
+ break;
+ }
+ };
+
+ // Handle PIN success
+ const handlePinSuccess = async () => {
+ setPinModalVisible(false);
+ if (currentServer && selectedAccount) {
+ setLoading(true);
+ try {
+ await loginWithSavedCredential(
+ currentServer.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,
+ );
+ } finally {
+ setLoading(false);
+ }
+ }
+ setSelectedAccount(null);
+ };
+
+ // Handle password submit
+ const handlePasswordSubmit = async (password: string) => {
+ if (currentServer && selectedAccount) {
+ setLoading(true);
+ try {
+ await loginWithPassword(
+ currentServer.address,
+ selectedAccount.username,
+ password,
+ );
+ } catch {
Alert.alert(
t("login.connection_failed"),
- t("login.could_not_connect_to_server"),
+ t("login.invalid_username_or_password"),
);
- return;
+ } finally {
+ setLoading(false);
}
- console.log("[TVLogin] Calling setServer with:", result);
- await setServer({ address: result });
- console.log("[TVLogin] setServer completed successfully");
- } catch (error) {
- console.error("[TVLogin] Error in handleConnect:", error);
}
- }, []);
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ };
+ // Handle forgot PIN
+ const handleForgotPIN = async () => {
+ setSelectedAccount(null);
+ setPinModalVisible(false);
+ };
+
+ // Handle login with credentials (from add user form)
+ const handleLogin = async (
+ username: string,
+ password: string,
+ saveAccount: boolean,
+ ) => {
+ if (!currentServer) return;
+
+ if (saveAccount) {
+ setPendingLogin({ username, password });
+ setShowSaveModal(true);
+ } else {
+ await performLogin(username, password);
+ }
+ };
+
+ const performLogin = async (
+ username: string,
+ password: string,
+ options?: {
+ saveAccount?: boolean;
+ securityType?: AccountSecurityType;
+ pinCode?: string;
+ },
+ ) => {
+ setLoading(true);
+ try {
+ await login(username, password, serverName, options);
+ } catch (error) {
+ if (error instanceof Error) {
+ Alert.alert(t("login.connection_failed"), error.message);
+ } else {
+ Alert.alert(
+ t("login.connection_failed"),
+ t("login.an_unexpected_error_occured"),
+ );
+ }
+ } finally {
+ setLoading(false);
+ setPendingLogin(null);
+ }
+ };
+
+ const handleSaveAccountConfirm = async (
+ securityType: AccountSecurityType,
+ pinCode?: string,
+ ) => {
+ setShowSaveModal(false);
+
+ if (pendingLogin && currentServer) {
+ setLoading(true);
+ try {
+ await login(pendingLogin.username, pendingLogin.password, serverName, {
+ saveAccount: true,
+ securityType,
+ pinCode,
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ Alert.alert(t("login.connection_failed"), error.message);
+ } else {
+ Alert.alert(
+ t("login.connection_failed"),
+ t("login.an_unexpected_error_occured"),
+ );
+ }
+ } finally {
+ setLoading(false);
+ setPendingLogin(null);
+ }
+ }
+ };
+
+ // Handle quick connect
const handleQuickConnect = async () => {
try {
const code = await initiateQuickConnect();
@@ -449,237 +452,89 @@ export const TVLogin: React.FC = () => {
}
};
- // Debug logging
- console.log("[TVLogin] Render - api?.basePath:", api?.basePath);
+ // Render current screen
+ const renderScreen = () => {
+ // If API is connected but we're on server/user selection,
+ // it means we need to show add-user form
+ if (api?.basePath && currentScreen !== "add-user") {
+ // API is ready, show add-user form
+ return (
+
+ );
+ }
+
+ switch (currentScreen) {
+ case "server-selection":
+ return (
+ setCurrentScreen("add-server")}
+ onDeleteServer={handleDeleteServer}
+ disabled={isAnyModalOpen}
+ />
+ );
+
+ case "user-selection":
+ if (!currentServer) {
+ setCurrentScreen("server-selection");
+ return null;
+ }
+ return (
+ {
+ // Set the server in JellyfinProvider and go to add-user
+ setServer({ address: currentServer.address });
+ setCurrentScreen("add-user");
+ }}
+ onChangeServer={handleChangeServer}
+ disabled={isAnyModalOpen || loading}
+ />
+ );
+
+ case "add-server":
+ return (
+ setCurrentScreen("server-selection")}
+ loading={loadingServerCheck}
+ disabled={isAnyModalOpen}
+ />
+ );
+
+ case "add-user":
+ return (
+ {
+ removeServer();
+ setCurrentScreen("user-selection");
+ }}
+ loading={loading}
+ disabled={isAnyModalOpen}
+ />
+ );
+
+ default:
+ return null;
+ }
+ };
return (
-
- {api?.basePath ? (
- // ==================== CREDENTIALS SCREEN ====================
-
-
- {/* Back Button */}
- removeServer()}
- label={t("login.change_server")}
- disabled={isAnyModalOpen}
- />
-
- {/* Title */}
-
- {serverName ? (
- <>
- {`${t("login.login_to_title")} `}
- {serverName}
- >
- ) : (
- t("login.login_title")
- )}
-
-
- {api.basePath}
-
-
- {/* Username Input - extra padding for focus scale */}
-
-
- setCredentials((prev) => ({ ...prev, username: text }))
- }
- autoCapitalize='none'
- autoCorrect={false}
- textContentType='username'
- returnKeyType='next'
- hasTVPreferredFocus
- disabled={isAnyModalOpen}
- />
-
-
- {/* Password Input */}
-
-
- setCredentials((prev) => ({ ...prev, password: text }))
- }
- secureTextEntry
- autoCapitalize='none'
- textContentType='password'
- returnKeyType='done'
- disabled={isAnyModalOpen}
- />
-
-
- {/* Save Account Toggle */}
-
-
-
-
- {/* Login Button */}
-
-
-
-
- {/* Quick Connect Button */}
-
-
-
- ) : (
- // ==================== SERVER SELECTION SCREEN ====================
-
-
- {/* Logo */}
-
-
-
-
- {/* Title */}
-
- Streamyfin
-
-
- {t("server.enter_url_to_jellyfin_server")}
-
-
- {/* Server URL Input - extra padding for focus scale */}
-
-
-
-
- {/* Connect Button */}
-
-
-
-
- {/* Previous Servers */}
-
- handleConnect(s.address)}
- onQuickLogin={handleQuickLoginWithSavedCredential}
- onPasswordLogin={handlePasswordLogin}
- onAddAccount={handleAddAccount}
- onPinRequired={handlePinRequired}
- onPasswordRequired={handlePasswordRequired}
- onServerAction={handleServerAction}
- loginServerOverride={loginTriggerServer}
- disabled={isAnyModalOpen}
- />
-
-
-
- )}
-
+ {renderScreen()}
{/* Save Account Modal */}
{
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
- username={pendingLogin?.username || credentials.username}
+ username={pendingLogin?.username || ""}
/>
{/* PIN Entry Modal */}
@@ -698,11 +553,10 @@ export const TVLogin: React.FC = () => {
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
- setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={handleForgotPIN}
- serverUrl={selectedServer?.address || ""}
+ serverUrl={currentServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
@@ -713,21 +567,10 @@ export const TVLogin: React.FC = () => {
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
- setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
-
- {/* Server Action Sheet */}
- setShowServerActionSheet(false)}
- />
);
};
diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx
index 25d9ce74..415cf2cf 100644
--- a/components/login/TVPINEntryModal.tsx
+++ b/components/login/TVPINEntryModal.tsx
@@ -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 (
{
+ setFocused(true);
+ animateTo(1.1);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
hasTVPreferredFocus={hasTVPreferredFocus}
+ disabled={disabled}
+ focusable={!disabled}
>
+ {isBackspace ? (
+
+ ) : (
+
+ {value}
+
+ )}
+
+
+ );
+};
+
+// PIN dot indicator
+const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({
+ filled,
+ error,
+}) => (
+
+);
+
+// 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 (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ >
+
{label}
@@ -80,23 +161,21 @@ export const TVPINEntryModal: React.FC = ({
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const [pinCode, setPinCode] = useState("");
- const [error, setError] = useState(null);
+ const [error, setError] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
- const pinInputRef = useRef(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 = ({
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 = ({
]).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 = ({
-
+
= ({
style={styles.content}
>
{/* Header */}
-
- {t("pin.enter_pin")}
-
- {t("pin.enter_pin_for", { username })}
-
-
+ {t("pin.enter_pin")}
+ {username}
- {/* PIN Input */}
+ {/* PIN Dots */}
+
+ {[0, 1, 2, 3].map((i) => (
+ i} error={error} />
+ ))}
+
+
+ {/* Number Pad */}
{isReady && (
-
-
- {error && {error}}
- {isVerifying && (
-
- {t("common.verifying")}
-
- )}
-
+
+ {/* Row 1: 1-3 */}
+
+ handleNumberPress("1")}
+ hasTVPreferredFocus
+ disabled={isVerifying}
+ />
+ handleNumberPress("2")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("3")}
+ disabled={isVerifying}
+ />
+
+ {/* Row 2: 4-6 */}
+
+ handleNumberPress("4")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("5")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("6")}
+ disabled={isVerifying}
+ />
+
+ {/* Row 3: 7-9 */}
+
+ handleNumberPress("7")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("8")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("9")}
+ disabled={isVerifying}
+ />
+
+ {/* Row 4: empty, 0, backspace */}
+
+
+ handleNumberPress("0")}
+ disabled={isVerifying}
+ />
+
+
+
)}
{/* Forgot PIN */}
{isReady && onForgotPIN && (
-
)}
@@ -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,
},
});
diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx
index 1473cf86..e4f0c358 100644
--- a/components/login/TVPasswordEntryModal.tsx
+++ b/components/login/TVPasswordEntryModal.tsx
@@ -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 ? (
-
+
) : (
<>
-
+
@@ -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 = ({
{/* Password Input */}
{isReady && (
- {t("login.password")}
+
+ {t("login.password_placeholder")}
+
{
setPassword(text);
setError(null);
}}
- placeholder={t("login.password")}
+ placeholder={t("login.password_placeholder")}
onSubmitEditing={handleSubmit}
hasTVPreferredFocus
/>
diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx
deleted file mode 100644
index 8f1d5aaf..00000000
--- a/components/login/TVPreviousServersList.tsx
+++ /dev/null
@@ -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 (
- {
- setFocused(true);
- animateTo(1.05);
- }}
- onBlur={() => {
- setFocused(false);
- animateTo(1);
- }}
- hasTVPreferredFocus={hasTVPreferredFocus}
- >
-
-
-
- {label}
-
-
-
- );
-};
-
-// 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 (
-
-
-
-
- {/* Title */}
-
- {server.name || server.address}
-
-
- {/* Horizontal options */}
-
-
-
-
-
-
-
-
-
- );
-};
-
-interface TVPreviousServersListProps {
- onServerSelect: (server: SavedServer) => void;
- onQuickLogin?: (serverUrl: string, userId: string) => Promise;
- onPasswordLogin?: (
- serverUrl: string,
- username: string,
- password: string,
- ) => Promise;
- 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 = ({
- onServerSelect,
- onQuickLogin,
- onAddAccount,
- onPinRequired,
- onPasswordRequired,
- onServerAction,
- loginServerOverride,
- disabled = false,
-}) => {
- const { t } = useTranslation();
- const [_previousServers, setPreviousServers] =
- useMMKVString("previousServers");
- const [loadingServer, setLoadingServer] = useState(null);
- const [selectedServer, setSelectedServer] = useState(
- 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 (
-
-
- {t("server.previous_servers")}
-
-
-
- {previousServers.map((server) => (
- handleServerPress(server)}
- disabled={disabled}
- />
- ))}
-
-
- {/* TV Account Selection Modal */}
- setShowAccountsModal(false)}
- >
-
-
-
- {t("server.select_account")}
-
-
- {selectedServer?.name || selectedServer?.address}
-
-
-
- {selectedServer?.accounts.map((account, index) => (
-
- selectedServer &&
- handleAccountLogin(selectedServer, account)
- }
- onLongPress={() => handleDeleteAccount(account)}
- hasTVPreferredFocus={index === 0}
- />
- ))}
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/components/login/TVSaveAccountModal.tsx b/components/login/TVSaveAccountModal.tsx
index a1c7d55c..39f4fb8c 100644
--- a/components/login/TVSaveAccountModal.tsx
+++ b/components/login/TVSaveAccountModal.tsx
@@ -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<{
},
]}
>
-
+
diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx
index 85ccc3f1..fc843256 100644
--- a/components/login/TVSaveAccountToggle.tsx
+++ b/components/login/TVSaveAccountToggle.tsx
@@ -1,7 +1,6 @@
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { Colors } from "@/constants/Colors";
interface TVSaveAccountToggleProps {
value: boolean;
@@ -62,7 +61,7 @@ export const TVSaveAccountToggle: React.FC = ({
style={[
{
transform: [{ scale }],
- shadowColor: "#a855f7",
+ shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
@@ -97,7 +96,7 @@ export const TVSaveAccountToggle: React.FC = ({
width: 60,
height: 34,
borderRadius: 17,
- backgroundColor: value ? Colors.primary : "#3f3f46",
+ backgroundColor: value ? "#fff" : "#3f3f46",
justifyContent: "center",
paddingHorizontal: 3,
}}
@@ -107,7 +106,7 @@ export const TVSaveAccountToggle: React.FC = ({
width: 28,
height: 28,
borderRadius: 14,
- backgroundColor: "white",
+ backgroundColor: value ? "#000" : "#fff",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>
diff --git a/components/login/TVServerCard.tsx b/components/login/TVServerCard.tsx
deleted file mode 100644
index a0e08ac2..00000000
--- a/components/login/TVServerCard.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import React, { useRef, useState } from "react";
-import {
- ActivityIndicator,
- Animated,
- Easing,
- Pressable,
- View,
-} from "react-native";
-import { fontSize, size } from "react-native-responsive-sizes";
-import { Text } from "@/components/common/Text";
-import { Colors } from "@/constants/Colors";
-
-interface TVServerCardProps {
- title: string;
- subtitle?: string;
- securityIcon?: keyof typeof Ionicons.glyphMap | null;
- isLoading?: boolean;
- onPress: () => void;
- hasTVPreferredFocus?: boolean;
- disabled?: boolean;
-}
-
-export const TVServerCard: React.FC = ({
- title,
- subtitle,
- securityIcon,
- isLoading,
- onPress,
- hasTVPreferredFocus,
- disabled = false,
-}) => {
- const [isFocused, setIsFocused] = useState(false);
- const scale = useRef(new Animated.Value(1)).current;
- const glowOpacity = useRef(new Animated.Value(0)).current;
-
- const animateFocus = (focused: boolean) => {
- Animated.parallel([
- Animated.timing(scale, {
- toValue: focused ? 1.02 : 1,
- duration: 150,
- easing: Easing.out(Easing.quad),
- useNativeDriver: true,
- }),
- Animated.timing(glowOpacity, {
- toValue: focused ? 0.7 : 0,
- duration: 150,
- easing: Easing.out(Easing.quad),
- useNativeDriver: true,
- }),
- ]).start();
- };
-
- const handleFocus = () => {
- setIsFocused(true);
- animateFocus(true);
- };
-
- const handleBlur = () => {
- setIsFocused(false);
- animateFocus(false);
- };
-
- const isDisabled = disabled || isLoading;
-
- return (
-
-
-
-
-
- {title}
-
- {subtitle && (
-
- {subtitle}
-
- )}
-
-
-
- {isLoading ? (
-
- ) : securityIcon ? (
-
-
-
-
- ) : (
-
- )}
-
-
-
-
- );
-};
diff --git a/components/login/TVServerIcon.tsx b/components/login/TVServerIcon.tsx
new file mode 100644
index 00000000..1ef01c51
--- /dev/null
+++ b/components/login/TVServerIcon.tsx
@@ -0,0 +1,211 @@
+import { LinearGradient } from "expo-linear-gradient";
+import React, { useMemo } 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";
+
+// Sci-fi gradient color pairs (from, to) - cyberpunk/neon vibes
+const SERVER_GRADIENTS: [string, string][] = [
+ ["#00D4FF", "#0066FF"], // Cyan to Blue
+ ["#FF00E5", "#7B00FF"], // Magenta to Purple
+ ["#00FF94", "#00B4D8"], // Neon Green to Cyan
+ ["#FF6B35", "#F72585"], // Orange to Pink
+ ["#4CC9F0", "#7209B7"], // Sky Blue to Violet
+ ["#06D6A0", "#118AB2"], // Mint to Ocean Blue
+ ["#FFD60A", "#FF006E"], // Yellow to Hot Pink
+ ["#8338EC", "#3A86FF"], // Purple to Blue
+ ["#FB5607", "#FFBE0B"], // Orange to Gold
+ ["#00F5D4", "#00BBF9"], // Aqua to Azure
+ ["#F15BB5", "#9B5DE5"], // Pink to Lavender
+ ["#00C49A", "#00509D"], // Teal to Navy
+ ["#E63946", "#F4A261"], // Red to Peach
+ ["#2EC4B6", "#011627"], // Turquoise to Dark Blue
+ ["#FF0099", "#493240"], // Hot Pink to Plum
+ ["#11998E", "#38EF7D"], // Teal to Lime
+ ["#FC466B", "#3F5EFB"], // Pink to Indigo
+ ["#C471ED", "#12C2E9"], // Orchid to Sky
+ ["#F857A6", "#FF5858"], // Pink to Coral
+ ["#00B09B", "#96C93D"], // Emerald to Lime
+ ["#7F00FF", "#E100FF"], // Violet to Magenta
+ ["#1FA2FF", "#12D8FA"], // Blue to Cyan
+ ["#F09819", "#EDDE5D"], // Orange to Yellow
+ ["#FF416C", "#FF4B2B"], // Pink to Red Orange
+ ["#654EA3", "#EAAFC8"], // Purple to Rose
+ ["#00C6FF", "#0072FF"], // Light Blue to Blue
+ ["#F7971E", "#FFD200"], // Orange to Gold
+ ["#56AB2F", "#A8E063"], // Green to Lime
+ ["#DA22FF", "#9733EE"], // Magenta to Purple
+ ["#02AAB0", "#00CDAC"], // Teal variations
+ ["#ED213A", "#93291E"], // Red to Dark Red
+ ["#FDC830", "#F37335"], // Yellow to Orange
+ ["#00B4DB", "#0083B0"], // Ocean Blue
+ ["#C33764", "#1D2671"], // Berry to Navy
+ ["#E55D87", "#5FC3E4"], // Pink to Sky Blue
+ ["#403B4A", "#E7E9BB"], // Dark to Cream
+ ["#F2709C", "#FF9472"], // Rose to Peach
+ ["#1D976C", "#93F9B9"], // Forest to Mint
+ ["#CC2B5E", "#753A88"], // Crimson to Purple
+ ["#42275A", "#734B6D"], // Plum shades
+ ["#BDC3C7", "#2C3E50"], // Silver to Slate
+ ["#DE6262", "#FFB88C"], // Salmon to Apricot
+ ["#06BEB6", "#48B1BF"], // Teal shades
+ ["#EB3349", "#F45C43"], // Red to Orange Red
+ ["#DD5E89", "#F7BB97"], // Pink to Tan
+ ["#56CCF2", "#2F80ED"], // Sky to Blue
+ ["#007991", "#78FFD6"], // Deep Teal to Mint
+ ["#C6FFDD", "#FBD786"], // Mint to Yellow
+ ["#F953C6", "#B91D73"], // Pink to Magenta
+ ["#B24592", "#F15F79"], // Purple to Coral
+];
+
+// Generate a consistent gradient index based on URL (deterministic hash)
+// Uses cyrb53 hash - fast and good distribution
+const getGradientForString = (str: string): [string, string] => {
+ let h1 = 0xdeadbeef;
+ let h2 = 0x41c6ce57;
+ for (let i = 0; i < str.length; i++) {
+ const ch = str.charCodeAt(i);
+ h1 = Math.imul(h1 ^ ch, 2654435761);
+ h2 = Math.imul(h2 ^ ch, 1597334677);
+ }
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
+ const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0);
+ const index = Math.abs(hash) % SERVER_GRADIENTS.length;
+ return SERVER_GRADIENTS[index];
+};
+
+export interface TVServerIconProps {
+ name: string;
+ address: string;
+ onPress: () => void;
+ onLongPress?: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+export const TVServerIcon = React.forwardRef(
+ (
+ {
+ name,
+ address,
+ onPress,
+ onLongPress,
+ hasTVPreferredFocus,
+ disabled = false,
+ },
+ ref,
+ ) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+
+ // Get the first letter of the server name (or address if no name)
+ const displayName = name || address;
+ const initial = displayName.charAt(0).toUpperCase();
+
+ // Get a consistent gradient based on the server URL (deterministic)
+ // Use address as primary key, fallback to name + displayName for uniqueness
+ const hashKey = address || name || displayName;
+ const [gradientStart, gradientEnd] = useMemo(
+ () => getGradientForString(hashKey),
+ [hashKey],
+ );
+
+ return (
+
+
+
+
+
+ {initial}
+
+
+
+
+
+ {displayName}
+
+
+ {name && (
+
+ {address.replace(/^https?:\/\//, "")}
+
+ )}
+
+
+ );
+ },
+);
diff --git a/components/login/TVServerSelectionScreen.tsx b/components/login/TVServerSelectionScreen.tsx
new file mode 100644
index 00000000..feff46df
--- /dev/null
+++ b/components/login/TVServerSelectionScreen.tsx
@@ -0,0 +1,137 @@
+import { Image } from "expo-image";
+import { t } from "i18next";
+import React, { useMemo } from "react";
+import { Alert, ScrollView, View } from "react-native";
+import { useMMKVString } from "react-native-mmkv";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import type { SavedServer } from "@/utils/secureCredentials";
+import { TVAddIcon } from "./TVAddIcon";
+import { TVServerIcon } from "./TVServerIcon";
+
+interface TVServerSelectionScreenProps {
+ onServerSelect: (server: SavedServer) => void;
+ onAddServer: () => void;
+ onDeleteServer: (server: SavedServer) => void;
+ disabled?: boolean;
+}
+
+export const TVServerSelectionScreen: React.FC<
+ TVServerSelectionScreenProps
+> = ({ onServerSelect, onAddServer, onDeleteServer, disabled = false }) => {
+ const typography = useScaledTVTypography();
+ const [_previousServers] = useMMKVString("previousServers");
+
+ const previousServers = useMemo(() => {
+ try {
+ return JSON.parse(_previousServers || "[]") as SavedServer[];
+ } catch {
+ return [];
+ }
+ }, [_previousServers]);
+
+ const hasServers = previousServers.length > 0;
+
+ const handleDeleteServer = (server: SavedServer) => {
+ Alert.alert(
+ t("server.remove_server"),
+ t("server.remove_server_description", {
+ server: server.name || server.address,
+ }),
+ [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.delete"),
+ style: "destructive",
+ onPress: () => onDeleteServer(server),
+ },
+ ],
+ );
+ };
+
+ return (
+
+
+ {/* Logo */}
+
+
+
+
+ {/* Title */}
+
+ Streamyfin
+
+
+ {hasServers
+ ? t("server.select_your_server")
+ : t("server.add_server_to_get_started")}
+
+
+ {/* Server Icons Grid */}
+
+ {previousServers.map((server, index) => (
+ onServerSelect(server)}
+ onLongPress={() => handleDeleteServer(server)}
+ hasTVPreferredFocus={index === 0}
+ disabled={disabled}
+ />
+ ))}
+
+ {/* Add Server Button */}
+
+
+
+
+ );
+};
diff --git a/components/login/TVUserIcon.tsx b/components/login/TVUserIcon.tsx
new file mode 100644
index 00000000..08aa7d4e
--- /dev/null
+++ b/components/login/TVUserIcon.tsx
@@ -0,0 +1,162 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import React, { useState } 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";
+import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
+import type { AccountSecurityType } from "@/utils/secureCredentials";
+
+export interface TVUserIconProps {
+ username: string;
+ securityType: AccountSecurityType;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ serverAddress?: string;
+ userId?: string;
+ primaryImageTag?: string;
+}
+
+export const TVUserIcon = React.forwardRef(
+ (
+ {
+ username,
+ securityType,
+ onPress,
+ hasTVPreferredFocus,
+ disabled = false,
+ serverAddress,
+ userId,
+ primaryImageTag,
+ },
+ ref,
+ ) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+ const [imageError, setImageError] = useState(false);
+
+ const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
+ switch (securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
+ const hasSecurityProtection = securityType !== "none";
+
+ const imageUrl =
+ serverAddress && userId && primaryImageTag && !imageError
+ ? getUserImageUrl({
+ serverAddress,
+ userId,
+ primaryImageTag,
+ width: 280,
+ })
+ : null;
+
+ return (
+
+
+
+
+ {imageUrl ? (
+ setImageError(true)}
+ />
+ ) : (
+
+ )}
+
+
+ {/* Security badge */}
+ {hasSecurityProtection && (
+
+
+
+ )}
+
+
+
+ {username}
+
+
+
+ );
+ },
+);
diff --git a/components/login/TVUserSelectionScreen.tsx b/components/login/TVUserSelectionScreen.tsx
new file mode 100644
index 00000000..d9f74b37
--- /dev/null
+++ b/components/login/TVUserSelectionScreen.tsx
@@ -0,0 +1,171 @@
+import { t } from "i18next";
+import React, { useEffect } from "react";
+import { BackHandler, Platform, ScrollView, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import type {
+ SavedServer,
+ SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { TVAddIcon } from "./TVAddIcon";
+import { TVBackIcon } from "./TVBackIcon";
+import { TVUserIcon } from "./TVUserIcon";
+
+interface TVUserSelectionScreenProps {
+ server: SavedServer;
+ onUserSelect: (account: SavedServerAccount) => void;
+ onAddUser: () => void;
+ onChangeServer: () => void;
+ disabled?: boolean;
+}
+
+// TV event handler with fallback for non-TV platforms
+let useTVEventHandler: (callback: (evt: any) => void) => void;
+if (Platform.isTV) {
+ try {
+ useTVEventHandler = require("react-native").useTVEventHandler;
+ } catch {
+ useTVEventHandler = () => {};
+ }
+} else {
+ useTVEventHandler = () => {};
+}
+
+export const TVUserSelectionScreen: React.FC = ({
+ server,
+ onUserSelect,
+ onAddUser,
+ onChangeServer,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+
+ const accounts = server.accounts || [];
+ const hasAccounts = accounts.length > 0;
+
+ // Handle TV remote back/menu button
+ useTVEventHandler((evt) => {
+ if (!evt || disabled) return;
+ if (evt.eventType === "menu" || evt.eventType === "back") {
+ onChangeServer();
+ }
+ });
+
+ // Handle Android TV back button
+ useEffect(() => {
+ if (!Platform.isTV) return;
+
+ const handleBackPress = () => {
+ if (disabled) return false;
+ onChangeServer();
+ return true;
+ };
+
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ handleBackPress,
+ );
+
+ return () => subscription.remove();
+ }, [onChangeServer, disabled]);
+
+ return (
+
+
+ {/* Server Info Header */}
+
+
+ {server.name || server.address}
+
+ {server.name && (
+
+ {server.address.replace(/^https?:\/\//, "")}
+
+ )}
+
+ {hasAccounts
+ ? t("login.select_user")
+ : t("login.add_user_to_login")}
+
+
+
+ {/* User Icons Grid with Back and Add buttons */}
+
+ {/* Back/Change Server Button (left) */}
+
+
+ {/* User Icons */}
+ {accounts.map((account, index) => (
+ onUserSelect(account)}
+ hasTVPreferredFocus={index === 0}
+ disabled={disabled}
+ serverAddress={server.address}
+ userId={account.userId}
+ primaryImageTag={account.primaryImageTag}
+ />
+ ))}
+
+ {/* Add User Button (right) */}
+
+
+
+
+ );
+};
diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx
index b731ab98..cab9d566 100644
--- a/components/persons/TVActorPage.tsx
+++ b/components/persons/TVActorPage.tsx
@@ -19,19 +19,19 @@ import {
Dimensions,
Easing,
FlatList,
- Pressable,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
-import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
-import MoviePoster, {
- TV_POSTER_WIDTH,
-} from "@/components/posters/MoviePoster.tv";
+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 { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
@@ -41,58 +41,8 @@ const { width: SCREEN_WIDTH } = Dimensions.get("window");
const HORIZONTAL_PADDING = 80;
const TOP_PADDING = 140;
const ACTOR_IMAGE_SIZE = 250;
-const ITEM_GAP = 16;
const SCALE_PADDING = 20;
-// Focusable poster wrapper component for TV
-const TVFocusablePoster: React.FC<{
- children: React.ReactNode;
- onPress: () => void;
- hasTVPreferredFocus?: boolean;
- onFocus?: () => void;
- onBlur?: () => void;
-}> = ({ children, onPress, hasTVPreferredFocus, onFocus, onBlur }) => {
- const [focused, setFocused] = useState(false);
- const scale = useRef(new Animated.Value(1)).current;
-
- const animateTo = (value: number) =>
- Animated.timing(scale, {
- toValue: value,
- duration: 150,
- easing: Easing.out(Easing.quad),
- useNativeDriver: true,
- }).start();
-
- return (
- {
- setFocused(true);
- animateTo(1.05);
- onFocus?.();
- }}
- onBlur={() => {
- setFocused(false);
- animateTo(1);
- onBlur?.();
- }}
- hasTVPreferredFocus={hasTVPreferredFocus}
- >
-
- {children}
-
-
- );
-};
-
interface TVActorPageProps {
personId: string;
}
@@ -101,8 +51,13 @@ export const TVActorPage: React.FC = ({ personId }) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
+ const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
+ const posterSizes = useScaledTVPosterSizes();
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
+ const ITEM_GAP = sizes.gaps.item;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -276,35 +231,47 @@ export const TVActorPage: React.FC = ({ personId }) => {
// List item layout
const getItemLayout = useCallback(
(_data: ArrayLike | 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,
}),
[],
);
- // Render filmography item
- const renderFilmographyItem = useCallback(
- (
- { item: filmItem, index }: { item: BaseItemDto; index: number },
- isFirstSection: boolean,
- ) => (
+ // Render movie filmography item
+ const renderMovieItem = useCallback(
+ ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
- handleItemPress(filmItem)}
+ onLongPress={() => showItemActions(filmItem)}
onFocus={() => setFocusedItem(filmItem)}
- hasTVPreferredFocus={isFirstSection && index === 0}
- >
-
-
-
-
-
-
-
+ hasTVPreferredFocus={index === 0}
+ width={posterSizes.poster}
+ />
),
- [handleItemPress],
+ [handleItemPress, showItemActions, posterSizes.poster],
+ );
+
+ // Render series filmography item
+ const renderSeriesItem = useCallback(
+ ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
+
+ handleItemPress(filmItem)}
+ onLongPress={() => showItemActions(filmItem)}
+ onFocus={() => setFocusedItem(filmItem)}
+ hasTVPreferredFocus={movies.length === 0 && index === 0}
+ width={posterSizes.poster}
+ />
+
+ ),
+ [handleItemPress, showItemActions, posterSizes.poster, movies.length],
);
if (isLoadingActor) {
@@ -374,28 +341,16 @@ export const TVActorPage: React.FC = ({ personId }) => {
)}
- {/* Gradient overlays for readability */}
+ {/* Gradient overlay for readability */}
-
@@ -459,7 +414,7 @@ export const TVActorPage: React.FC = ({ personId }) => {
{/* Actor name */}
= ({ personId }) => {
{item.ProductionYear && (
= ({ personId }) => {
{item.Overview && (
= ({ personId }) => {
= ({ personId }) => {
horizontal
data={movies}
keyExtractor={(filmItem) => filmItem.Id!}
- renderItem={(props) => renderFilmographyItem(props, true)}
+ renderItem={renderMovieItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={6}
maxToRenderPerBatch={4}
@@ -563,7 +518,7 @@ export const TVActorPage: React.FC = ({ personId }) => {
= ({ personId }) => {
horizontal
data={series}
keyExtractor={(filmItem) => filmItem.Id!}
- renderItem={(props) =>
- renderFilmographyItem(props, movies.length === 0)
- }
+ renderItem={renderSeriesItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={6}
maxToRenderPerBatch={4}
@@ -603,7 +556,7 @@ export const TVActorPage: React.FC = ({ personId }) => {
diff --git a/components/posters/MoviePoster.tv.tsx b/components/posters/MoviePoster.tv.tsx
deleted file mode 100644
index 1719df96..00000000
--- a/components/posters/MoviePoster.tv.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-import { WatchedIndicator } from "@/components/WatchedIndicator";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
-
-export const TV_POSTER_WIDTH = 210;
-
-type MoviePosterProps = {
- item: BaseItemDto;
- showProgress?: boolean;
-};
-
-const MoviePoster: React.FC = ({
- item,
- showProgress = false,
-}) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(() => {
- return getPrimaryImageUrl({
- api,
- item,
- width: 420, // 2x for quality on large screens
- });
- }, [api, item]);
-
- const progress = item.UserData?.PlayedPercentage || 0;
-
- const blurhash = useMemo(() => {
- const key = item.ImageTags?.Primary as string;
- return item.ImageBlurHashes?.Primary?.[key];
- }, [item]);
-
- return (
-
-
-
- {showProgress && progress > 0 && (
-
- )}
-
- );
-};
-
-export default MoviePoster;
diff --git a/components/posters/SeriesPoster.tv.tsx b/components/posters/SeriesPoster.tv.tsx
deleted file mode 100644
index 21b41ff6..00000000
--- a/components/posters/SeriesPoster.tv.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
-
-export const TV_POSTER_WIDTH = 210;
-
-type SeriesPosterProps = {
- item: BaseItemDto;
- showProgress?: boolean;
-};
-
-const SeriesPoster: React.FC = ({ item }) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(() => {
- if (item.Type === "Episode") {
- return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`;
- }
- return getPrimaryImageUrl({
- api,
- item,
- width: 420, // 2x for quality on large screens
- });
- }, [api, item]);
-
- const blurhash = useMemo(() => {
- const key = item.ImageTags?.Primary as string;
- return item.ImageBlurHashes?.Primary?.[key];
- }, [item]);
-
- return (
-
-
-
- );
-};
-
-export default SeriesPoster;
diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx
index d696e9d4..cba3a554 100644
--- a/components/search/TVJellyseerrSearchResults.tsx
+++ b/components/search/TVJellyseerrSearchResults.tsx
@@ -5,7 +5,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 { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type {
@@ -27,6 +27,7 @@ const TVJellyseerrPoster: React.FC = ({
onPress,
isFirstItem = false,
}) => {
+ const typography = useScaledTVTypography();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -113,7 +114,7 @@ const TVJellyseerrPoster: React.FC = ({
= ({
{year && (
= ({
item,
onPress,
}) => {
+ const typography = useScaledTVTypography();
const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.08 });
+ useTVFocusAnimation();
const posterUrl = item.profilePath
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
@@ -202,7 +204,7 @@ const TVJellyseerrPersonPoster: React.FC = ({
= ({
isFirstSection = false,
onItemPress,
}) => {
+ const typography = useScaledTVTypography();
if (!items || items.length === 0) return null;
return (
= ({
isFirstSection = false,
onItemPress,
}) => {
+ const typography = useScaledTVTypography();
if (!items || items.length === 0) return null;
return (
= ({
isFirstSection: _isFirstSection = false,
onItemPress,
}) => {
+ const typography = useScaledTVTypography();
if (!items || items.length === 0) return null;
return (
= ({
onPress,
hasTVPreferredFocus = false,
}) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
+ useTVFocusAnimation({ duration: 150 });
return (
= ({
>
{
+ const typography = useScaledTVTypography();
const itemWidth = 210;
return (
@@ -71,7 +73,7 @@ const TVLoadingSkeleton: React.FC = () => {
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
- fontSize: 16,
+ fontSize: typography.callout,
}}
numberOfLines={1}
>
@@ -104,6 +106,7 @@ interface TVSearchPageProps {
loading: boolean;
noResults: boolean;
onItemPress: (item: BaseItemDto) => void;
+ onItemLongPress?: (item: BaseItemDto) => void;
// Jellyseerr/Discover props
searchType: SearchType;
setSearchType: (type: SearchType) => void;
@@ -136,6 +139,7 @@ export const TVSearchPage: React.FC = ({
loading,
noResults,
onItemPress,
+ onItemLongPress,
searchType,
setSearchType,
showDiscover,
@@ -149,6 +153,7 @@ export const TVSearchPage: React.FC = ({
onJellyseerrPersonPress,
discoverSliders,
}) => {
+ const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
@@ -217,12 +222,15 @@ export const TVSearchPage: React.FC = ({
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
- paddingLeft: insets.left + HORIZONTAL_PADDING,
- paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
{/* Search Input */}
-
+
= ({
{/* Search Type Tab Badges */}
{showDiscover && (
-
+
= ({
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
onItemPress={onItemPress}
+ onItemLongPress={onItemLongPress}
imageUrlGetter={
["artists", "albums", "songs", "playlists"].includes(
section.key,
@@ -307,7 +316,7 @@ export const TVSearchPage: React.FC = ({
= ({
>
{t("search.no_results_found_for")}
-
+
"{debouncedSearch}"
diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx
index 9f2152c5..5fd3dd37 100644
--- a/components/search/TVSearchSection.tsx
+++ b/components/search/TVSearchSection.tsx
@@ -2,95 +2,15 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useCallback, useEffect, useRef, useState } from "react";
import { FlatList, View, type ViewProps } from "react-native";
-import ContinueWatchingPoster, {
- TV_LANDSCAPE_WIDTH,
-} from "@/components/ContinueWatchingPoster.tv";
import { Text } from "@/components/common/Text";
-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 { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
-const ITEM_GAP = 16;
const SCALE_PADDING = 20;
-// TV-specific ItemCardText with larger fonts
-const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
- return (
-
- {item.Type === "Episode" ? (
- <>
-
- {item.Name}
-
-
- {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
- {" - "}
- {item.SeriesName}
-
- >
- ) : item.Type === "MusicArtist" ? (
-
- {item.Name}
-
- ) : item.Type === "MusicAlbum" ? (
- <>
-
- {item.Name}
-
-
- {item.AlbumArtist || item.Artists?.join(", ")}
-
- >
- ) : item.Type === "Audio" ? (
- <>
-
- {item.Name}
-
-
- {item.Artists?.join(", ") || item.AlbumArtist}
-
- >
- ) : item.Type === "Playlist" ? (
- <>
-
- {item.Name}
-
-
- {item.ChildCount} tracks
-
- >
- ) : item.Type === "Person" ? (
-
- {item.Name}
-
- ) : (
- <>
-
- {item.Name}
-
-
- {item.ProductionYear}
-
- >
- )}
-
- );
-};
-
interface TVSearchSectionProps extends ViewProps {
title: string;
items: BaseItemDto[];
@@ -98,6 +18,7 @@ interface TVSearchSectionProps extends ViewProps {
disabled?: boolean;
isFirstSection?: boolean;
onItemPress: (item: BaseItemDto) => void;
+ onItemLongPress?: (item: BaseItemDto) => void;
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
}
@@ -108,18 +29,20 @@ export const TVSearchSection: React.FC = ({
disabled = false,
isFirstSection = false,
onItemPress,
+ onItemLongPress,
imageUrlGetter,
...props
}) => {
+ const typography = useScaledTVTypography();
+ const posterSizes = useScaledTVPosterSizes();
+ const sizes = useScaledTVSizes();
+ const ITEM_GAP = sizes.gaps.item;
const flatListRef = useRef>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
- // When section loses all focus, scroll back to start
+ // Track focus count for section
useEffect(() => {
- if (prevFocusedCount.current > 0 && focusedCount === 0) {
- flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
- }
prevFocusedCount.current = focusedCount;
}, [focusedCount]);
@@ -132,7 +55,7 @@ export const TVSearchSection: React.FC = ({
}, []);
const itemWidth =
- orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
+ orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
const getItemLayout = useCallback(
(_data: ArrayLike | null | undefined, index: number) => ({
@@ -140,155 +63,186 @@ export const TVSearchSection: React.FC = ({
offset: (itemWidth + ITEM_GAP) * index,
index,
}),
- [itemWidth],
+ [itemWidth, ITEM_GAP],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
- const isHorizontal = orientation === "horizontal";
- const renderPoster = () => {
- // Music Artist - circular avatar
- if (item.Type === "MusicArtist") {
- const imageUrl = imageUrlGetter?.(item);
- return (
-
+ onItemPress(item)}
+ onLongPress={
+ onItemLongPress ? () => onItemLongPress(item) : undefined
+ }
+ hasTVPreferredFocus={isFirstItem && !disabled}
+ onFocus={handleItemFocus}
+ onBlur={handleItemBlur}
+ disabled={disabled}
>
- {imageUrl ? (
-
- ) : (
-
- 👤
-
- )}
+
+ {imageUrl ? (
+
+ ) : (
+
+ 👤
+
+ )}
+
+
+
+
+ {item.Name}
+
- );
- }
-
- // Music Album, Audio, Playlist - square images
- if (
- item.Type === "MusicAlbum" ||
- item.Type === "Audio" ||
- item.Type === "Playlist"
- ) {
- const imageUrl = imageUrlGetter?.(item);
- const icon =
- item.Type === "Playlist"
- ? "🎶"
- : item.Type === "Audio"
- ? "🎵"
- : "🎵";
- return (
-
- {imageUrl ? (
-
- ) : (
-
- {icon}
-
- )}
-
- );
- }
-
- // Person (Actor)
- if (item.Type === "Person") {
- return ;
- }
-
- // Episode rendering
- if (item.Type === "Episode" && isHorizontal) {
- return ;
- }
- if (item.Type === "Episode" && !isHorizontal) {
- return ;
- }
-
- // Movie rendering
- if (item.Type === "Movie" && isHorizontal) {
- return ;
- }
- if (item.Type === "Movie" && !isHorizontal) {
- return ;
- }
-
- // Series rendering
- if (item.Type === "Series" && !isHorizontal) {
- return ;
- }
- if (item.Type === "Series" && isHorizontal) {
- return ;
- }
-
- // BoxSet (Collection)
- if (item.Type === "BoxSet" && !isHorizontal) {
- return ;
- }
- if (item.Type === "BoxSet" && isHorizontal) {
- return ;
- }
-
- // Default fallback
- return isHorizontal ? (
-
- ) : (
-
+
);
- };
+ }
- // Special width for music artists (circular)
- const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth;
+ // Special handling for MusicAlbum, Audio, Playlist (square images)
+ if (
+ item.Type === "MusicAlbum" ||
+ item.Type === "Audio" ||
+ item.Type === "Playlist"
+ ) {
+ const imageUrl = imageUrlGetter?.(item);
+ const icon =
+ item.Type === "Playlist" ? "🎶" : item.Type === "Audio" ? "🎵" : "🎵";
+ return (
+
+ onItemPress(item)}
+ onLongPress={
+ onItemLongPress ? () => onItemLongPress(item) : undefined
+ }
+ hasTVPreferredFocus={isFirstItem && !disabled}
+ onFocus={handleItemFocus}
+ onBlur={handleItemBlur}
+ disabled={disabled}
+ >
+
+ {imageUrl ? (
+
+ ) : (
+
+ {icon}
+
+ )}
+
+
+
+
+ {item.Name}
+
+ {item.Type === "MusicAlbum" && (
+
+ {item.AlbumArtist || item.Artists?.join(", ")}
+
+ )}
+ {item.Type === "Audio" && (
+
+ {item.Artists?.join(", ") || item.AlbumArtist}
+
+ )}
+ {item.Type === "Playlist" && (
+
+ {item.ChildCount} tracks
+
+ )}
+
+
+ );
+ }
+ // Use TVPosterCard for all other item types
return (
-
-
+ onItemPress(item)}
+ onLongPress={
+ onItemLongPress ? () => onItemLongPress(item) : undefined
+ }
hasTVPreferredFocus={isFirstItem && !disabled}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
disabled={disabled}
- >
- {renderPoster()}
-
-
+ width={itemWidth}
+ />
);
},
@@ -297,10 +251,14 @@ export const TVSearchSection: React.FC = ({
isFirstSection,
itemWidth,
onItemPress,
+ onItemLongPress,
handleItemFocus,
handleItemBlur,
disabled,
imageUrlGetter,
+ posterSizes.poster,
+ typography.callout,
+ ITEM_GAP,
],
);
@@ -311,11 +269,12 @@ export const TVSearchSection: React.FC = ({
{/* Section Header */}
{title}
@@ -334,9 +293,13 @@ export const TVSearchSection: React.FC = ({
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,
}}
/>
diff --git a/components/search/TVSearchTabBadges.tsx b/components/search/TVSearchTabBadges.tsx
index bce7390d..d15d43ce 100644
--- a/components/search/TVSearchTabBadges.tsx
+++ b/components/search/TVSearchTabBadges.tsx
@@ -2,6 +2,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 { useScaledTVTypography } from "@/constants/TVTypography";
type SearchType = "Library" | "Discover";
@@ -20,8 +21,9 @@ const TVSearchTabBadge: React.FC = ({
hasTVPreferredFocus = false,
disabled = false,
}) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
+ useTVFocusAnimation({ duration: 150 });
// Design language: white for focused/selected, transparent white for unfocused
const getBackgroundColor = () => {
@@ -61,7 +63,7 @@ const TVSearchTabBadge: React.FC = ({
>
void;
- onFocus?: () => void;
- onBlur?: () => void;
- /** Setter function for the ref (for focus guide destinations) */
- refSetter?: (ref: View | null) => void;
-}
-
-export const TVEpisodeCard: React.FC = ({
- episode,
- hasTVPreferredFocus = false,
- disabled = false,
- onPress,
- onFocus,
- onBlur,
- refSetter,
-}) => {
- const api = useAtomValue(apiAtom);
-
- const thumbnailUrl = useMemo(() => {
- if (!api) return null;
-
- // Try to get episode primary image first
- if (episode.ImageTags?.Primary) {
- return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80&tag=${episode.ImageTags.Primary}`;
- }
-
- // Fall back to series thumb or backdrop
- if (episode.ParentBackdropItemId && episode.ParentThumbImageTag) {
- return `${api.basePath}/Items/${episode.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${episode.ParentThumbImageTag}`;
- }
-
- // Default episode image
- return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80`;
- }, [api, episode]);
-
- const duration = useMemo(() => {
- if (!episode.RunTimeTicks) return null;
- return runtimeTicksToMinutes(episode.RunTimeTicks);
- }, [episode.RunTimeTicks]);
-
- const episodeLabel = useMemo(() => {
- const season = episode.ParentIndexNumber;
- const ep = episode.IndexNumber;
- if (season !== undefined && ep !== undefined) {
- return `S${season}:E${ep}`;
- }
- return null;
- }, [episode.ParentIndexNumber, episode.IndexNumber]);
-
- return (
-
-
-
- {thumbnailUrl ? (
-
- ) : (
-
- )}
-
-
-
-
-
- {/* Episode info below thumbnail */}
-
-
- {episodeLabel && (
-
- {episodeLabel}
-
- )}
- {duration && (
- <>
-
- •
-
-
- {duration}
-
- >
- )}
-
-
- {episode.Name}
-
-
-
- );
-};
diff --git a/components/series/TVEpisodeList.tsx b/components/series/TVEpisodeList.tsx
new file mode 100644
index 00000000..0f271320
--- /dev/null
+++ b/components/series/TVEpisodeList.tsx
@@ -0,0 +1,89 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import React, { useCallback } from "react";
+import { ScrollView, View } from "react-native";
+import { TVHorizontalList } from "@/components/tv/TVHorizontalList";
+import { TVPosterCard } from "@/components/tv/TVPosterCard";
+
+interface TVEpisodeListProps {
+ episodes: BaseItemDto[];
+ /** Shows "Now Playing" badge on the episode matching this ID */
+ currentEpisodeId?: string;
+ /** Disable all cards (e.g., when modal is open) */
+ disabled?: boolean;
+ /** Handler when an episode is pressed */
+ onEpisodePress: (episode: BaseItemDto) => void;
+ /** Called when any episode is long-pressed */
+ onEpisodeLongPress?: (episode: BaseItemDto) => void;
+ /** Called when any episode gains focus */
+ onFocus?: () => void;
+ /** Called when any episode loses focus */
+ onBlur?: () => void;
+ /** Ref for programmatic scrolling */
+ scrollViewRef?: React.RefObject;
+ /** Setter for the first episode ref (for focus guide destinations) */
+ firstEpisodeRefSetter?: (ref: View | null) => void;
+ /** Text to show when episodes array is empty */
+ emptyText?: string;
+ /** Horizontal padding for the list content */
+ horizontalPadding?: number;
+}
+
+export const TVEpisodeList: React.FC = ({
+ episodes,
+ currentEpisodeId,
+ disabled = false,
+ onEpisodePress,
+ onEpisodeLongPress,
+ onFocus,
+ onBlur,
+ scrollViewRef,
+ firstEpisodeRefSetter,
+ emptyText,
+ horizontalPadding,
+}) => {
+ const renderItem = useCallback(
+ ({ item: episode, index }: { item: BaseItemDto; index: number }) => {
+ const isCurrent = currentEpisodeId
+ ? episode.Id === currentEpisodeId
+ : false;
+ return (
+ onEpisodePress(episode)}
+ onLongPress={
+ onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined
+ }
+ onFocus={onFocus}
+ onBlur={onBlur}
+ disabled={isCurrent || disabled}
+ focusableWhenDisabled={isCurrent}
+ isCurrent={isCurrent}
+ refSetter={index === 0 ? firstEpisodeRefSetter : undefined}
+ />
+ );
+ },
+ [
+ currentEpisodeId,
+ disabled,
+ firstEpisodeRefSetter,
+ onBlur,
+ onEpisodeLongPress,
+ onEpisodePress,
+ onFocus,
+ ],
+ );
+
+ const keyExtractor = useCallback((episode: BaseItemDto) => episode.Id!, []);
+
+ return (
+
+ );
+};
diff --git a/components/series/TVSeriesHeader.tsx b/components/series/TVSeriesHeader.tsx
index 02d16148..b76bcd68 100644
--- a/components/series/TVSeriesHeader.tsx
+++ b/components/series/TVSeriesHeader.tsx
@@ -8,7 +8,7 @@ import { Dimensions, View } from "react-native";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
@@ -19,6 +19,7 @@ interface TVSeriesHeaderProps {
}
export const TVSeriesHeader: React.FC = ({ item }) => {
+ const typography = useScaledTVTypography();
const api = useAtomValue(apiAtom);
const logoUrl = useMemo(() => {
@@ -58,7 +59,7 @@ export const TVSeriesHeader: React.FC = ({ item }) => {
) : (
= ({ item }) => {
}}
>
{yearString && (
-
+
{yearString}
)}
@@ -123,7 +124,7 @@ export const TVSeriesHeader: React.FC = ({ item }) => {
>
void;
disabled?: boolean;
}> = ({ seasonName, onPress, disabled = false }) => {
+ const typography = useScaledTVTypography();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -190,7 +193,7 @@ const TVSeasonButton: React.FC<{
>
= ({
allEpisodes = [],
isLoading: _isLoading,
}) => {
+ const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -223,9 +227,13 @@ export const TVSeriesPage: React.FC = ({
const [user] = useAtom(userAtom);
const { getDownloadedItems, downloadedItems } = useDownload();
const { showSeasonModal } = useTVSeriesSeasonModal();
+ const { showItemActions } = useTVItemActionModal();
const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom);
const isSeasonModalVisible = seasonModalState !== null;
+ // Auto-play theme music (handles fade in/out and cleanup)
+ useTVThemeMusic(item.Id);
+
// Season state
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const selectedSeasonIndex = useMemo(
@@ -244,24 +252,13 @@ export const TVSeriesPage: React.FC = ({
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
- // Scroll back to start when episode list loses focus
+ // Track focus count for episode list
useEffect(() => {
- if (prevFocusedCount.current > 0 && focusedCount === 0) {
- episodeListRef.current?.scrollTo({ x: 0, animated: true });
- // Scroll page back to top when leaving episode section
- mainScrollRef.current?.scrollTo({ y: 0, animated: true });
- }
prevFocusedCount.current = focusedCount;
}, [focusedCount]);
const handleEpisodeFocus = useCallback(() => {
- setFocusedCount((c) => {
- // Scroll page down when first episode receives focus
- if (c === 0) {
- mainScrollRef.current?.scrollTo({ y: 200, animated: true });
- }
- return c + 1;
- });
+ setFocusedCount((c) => c + 1);
}, []);
const handleEpisodeBlur = useCallback(() => {
@@ -293,6 +290,7 @@ export const TVSeriesPage: React.FC = ({
return response.data.Items || [];
},
staleTime: isOffline ? Infinity : 60 * 1000,
+ refetchInterval: !isOffline ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
});
@@ -345,7 +343,8 @@ export const TVSeriesPage: React.FC = ({
});
return res.data.Items || [];
},
- staleTime: isOffline ? Infinity : 0,
+ staleTime: isOffline ? Infinity : 60 * 1000,
+ refetchInterval: !isOffline ? 60 * 1000 : undefined,
enabled: isOffline
? !!item.Id && selectedSeasonNumber !== null
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
@@ -505,40 +504,14 @@ export const TVSeriesPage: React.FC = ({
}}
showsVerticalScrollIndicator={false}
>
- {/* Top section - Poster + Content */}
+ {/* Top section - Content + Poster */}
- {/* Left side - Poster */}
-
-
-
-
-
-
- {/* Right side - Content */}
+ {/* Left side - Content */}
@@ -565,7 +538,7 @@ export const TVSeriesPage: React.FC = ({
/>
= ({
disabled={isSeasonModalVisible}
/>
)}
+
+
+
+
+
+ {/* Right side - Poster */}
+
+
+
@@ -589,10 +590,10 @@ export const TVSeriesPage: React.FC = ({
@@ -615,43 +616,18 @@ export const TVSeriesPage: React.FC = ({
/>
)}
-
- {episodesForSeason.length > 0 ? (
- episodesForSeason.map((episode, index) => (
- handleEpisodePress(episode)}
- onFocus={handleEpisodeFocus}
- onBlur={handleEpisodeBlur}
- disabled={isSeasonModalVisible}
- // Pass refSetter to first episode for focus guide destination
- // Note: Do NOT use hasTVPreferredFocus on focus guide destinations
- refSetter={index === 0 ? setFirstEpisodeRef : undefined}
- />
- ))
- ) : (
-
- {t("item_card.no_episodes_for_this_season")}
-
- )}
-
+
diff --git a/components/settings/AppearanceSettings.tsx b/components/settings/AppearanceSettings.tsx
index f9074213..84409617 100644
--- a/components/settings/AppearanceSettings.tsx
+++ b/components/settings/AppearanceSettings.tsx
@@ -42,14 +42,6 @@ export const AppearanceSettings: React.FC = () => {
}
/>
-
-
- updateSettings({ showLargeHomeCarousel: value })
- }
- />
-
diff --git a/components/settings/MpvBufferSettings.tsx b/components/settings/MpvBufferSettings.tsx
new file mode 100644
index 00000000..6df37412
--- /dev/null
+++ b/components/settings/MpvBufferSettings.tsx
@@ -0,0 +1,100 @@
+import { Ionicons } from "@expo/vector-icons";
+import type React from "react";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { View } from "react-native";
+import { Stepper } from "@/components/inputs/Stepper";
+import { PlatformDropdown } from "@/components/PlatformDropdown";
+import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
+import { Text } from "../common/Text";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
+
+const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
+ { key: "home.settings.buffer.cache_auto", value: "auto" },
+ { key: "home.settings.buffer.cache_yes", value: "yes" },
+ { key: "home.settings.buffer.cache_no", value: "no" },
+];
+
+export const MpvBufferSettings: React.FC = () => {
+ const { settings, updateSettings } = useSettings();
+ const { t } = useTranslation();
+
+ const cacheModeOptions = useMemo(
+ () => [
+ {
+ options: CACHE_MODE_OPTIONS.map((option) => ({
+ type: "radio" as const,
+ label: t(option.key),
+ value: option.value,
+ selected: option.value === (settings?.mpvCacheEnabled ?? "auto"),
+ onPress: () => updateSettings({ mpvCacheEnabled: option.value }),
+ })),
+ },
+ ],
+ [settings?.mpvCacheEnabled, t, updateSettings],
+ );
+
+ const currentCacheModeLabel = useMemo(() => {
+ const option = CACHE_MODE_OPTIONS.find(
+ (o) => o.value === (settings?.mpvCacheEnabled ?? "auto"),
+ );
+ return option ? t(option.key) : t("home.settings.buffer.cache_auto");
+ }, [settings?.mpvCacheEnabled, t]);
+
+ if (!settings) return null;
+
+ return (
+
+
+
+
+ {currentCacheModeLabel}
+
+
+
+ }
+ title={t("home.settings.buffer.cache_mode")}
+ />
+
+
+
+ updateSettings({ mpvCacheSeconds: value })}
+ appendValue='s'
+ />
+
+
+
+ updateSettings({ mpvDemuxerMaxBytes: value })}
+ appendValue=' MB'
+ />
+
+
+
+
+ updateSettings({ mpvDemuxerMaxBackBytes: value })
+ }
+ appendValue=' MB'
+ />
+
+
+ );
+};
diff --git a/components/settings/MpvSubtitleSettings.tsx b/components/settings/MpvSubtitleSettings.tsx
index 0ceae68b..4ef2a800 100644
--- a/components/settings/MpvSubtitleSettings.tsx
+++ b/components/settings/MpvSubtitleSettings.tsx
@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
-import { Platform, View, type ViewProps } from "react-native";
+import { Platform, Switch, View, type ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
@@ -55,7 +55,6 @@ export const MpvSubtitleSettings: React.FC = ({ ...props }) => {
return [{ options }];
}, [settings?.mpvSubtitleAlignY, updateSettings]);
- if (isTv) return null;
if (!settings) return null;
return (
@@ -68,65 +67,83 @@ export const MpvSubtitleSettings: React.FC = ({ ...props }) => {
}
>
-
-
- updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 })
+ {!isTv && (
+ <>
+
+
+ updateSettings({ mpvSubtitleMarginY: value })
+ }
+ />
+
+
+
+
+
+ {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]}
+
+
+
+ }
+ title='Horizontal Alignment'
+ />
+
+
+
+
+
+ {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]}
+
+
+
+ }
+ title='Vertical Alignment'
+ />
+
+ >
+ )}
+
+
+
+ updateSettings({ mpvSubtitleBackgroundEnabled: value })
}
/>
-
- updateSettings({ mpvSubtitleMarginY: value })}
- />
-
-
-
-
-
- {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]}
-
-
-
- }
- title='Horizontal Alignment'
- />
-
-
-
-
-
- {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]}
-
-
-
- }
- title='Vertical Alignment'
- />
-
+ {settings.mpvSubtitleBackgroundEnabled && (
+
+
+ updateSettings({ mpvSubtitleBackgroundOpacity: value })
+ }
+ />
+
+ )}
);
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index fcca2498..7abf10fb 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -158,14 +158,6 @@ export const OtherSettings: React.FC = () => {
}
/>
-
-
- updateSettings({ showLargeHomeCarousel: value })
- }
- />
-
router.push("/settings/hide-libraries/page")}
title={t("home.settings.other.hide_libraries")}
diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx
index 47caa43d..77f5453e 100644
--- a/components/settings/SubtitleToggles.tsx
+++ b/components/settings/SubtitleToggles.tsx
@@ -166,13 +166,13 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
disabled={pluginSettings?.subtitleSize?.locked}
>
- updateSettings({ subtitleSize: Math.round(value * 100) })
+ updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 })
}
/>
diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx
index 888da829..fba7ce1c 100644
--- a/components/tv/TVActorCard.tsx
+++ b/components/tv/TVActorCard.tsx
@@ -3,7 +3,7 @@ import { Image } from "expo-image";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVActorCardProps {
@@ -19,8 +19,9 @@ export interface TVActorCardProps {
export const TVActorCard = React.forwardRef(
({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.08 });
+ useTVFocusAnimation();
const imageUrl = person.Id
? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=280&fillHeight=280&quality=90`
@@ -55,8 +56,8 @@ export const TVActorCard = React.forwardRef(
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 14,
- borderWidth: focused ? 3 : 0,
- borderColor: "#fff",
+ borderWidth: 2,
+ borderColor: focused ? "#FFFFFF" : "transparent",
}}
>
{imageUrl ? (
@@ -84,7 +85,7 @@ export const TVActorCard = React.forwardRef(
(
{person.Role && (
= ({
label = "Cancel",
disabled = false,
}) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
@@ -48,7 +49,7 @@ export const TVCancelButton: React.FC = ({
/>
= React.memo(
({ director, cast, hideCast = false }) => {
+ const typography = useScaledTVTypography();
const { t } = useTranslation();
if (!director && (!cast || cast.length === 0)) {
@@ -24,7 +25,7 @@ export const TVCastCrewText: React.FC = React.memo(
= React.memo(
= React.memo(
>
{t("item_card.director")}
-
+
{director.Name}
@@ -55,7 +56,7 @@ export const TVCastCrewText: React.FC = React.memo(
= React.memo(
>
{t("item_card.cast")}
-
+
{cast.map((c) => c.Name).join(", ")}
diff --git a/components/tv/TVCastSection.tsx b/components/tv/TVCastSection.tsx
index 828ca3f6..95c5f3d1 100644
--- a/components/tv/TVCastSection.tsx
+++ b/components/tv/TVCastSection.tsx
@@ -3,7 +3,8 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, TVFocusGuideView, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVActorCard } from "./TVActorCard";
export interface TVCastSectionProps {
@@ -24,6 +25,8 @@ export const TVCastSection: React.FC = React.memo(
firstActorRefSetter,
upwardFocusDestination,
}) => {
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
const { t } = useTranslation();
if (cast.length === 0) {
@@ -34,7 +37,7 @@ export const TVCastSection: React.FC = React.memo(
= React.memo(
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 16,
- gap: 28,
+ gap: sizes.gaps.item,
}}
>
{cast.map((person, index) => (
diff --git a/components/tv/TVFavoriteButton.tsx b/components/tv/TVFavoriteButton.tsx
index 6be3f977..330934e0 100644
--- a/components/tv/TVFavoriteButton.tsx
+++ b/components/tv/TVFavoriteButton.tsx
@@ -6,13 +6,22 @@ import { TVButton } from "./TVButton";
export interface TVFavoriteButtonProps {
item: BaseItemDto;
+ disabled?: boolean;
}
-export const TVFavoriteButton: React.FC = ({ item }) => {
+export const TVFavoriteButton: React.FC = ({
+ item,
+ disabled,
+}) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
return (
-
+
void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ hasActiveFilter?: boolean;
+}
+
+export const TVFilterButton: React.FC = ({
+ label,
+ value,
+ onPress,
+ hasTVPreferredFocus = false,
+ disabled = false,
+ hasActiveFilter = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.04, duration: 120 });
+
+ return (
+
+
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ {value}
+
+
+
+
+ );
+};
diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx
index 3ae0e214..337cbc2a 100644
--- a/components/tv/TVFocusablePoster.tsx
+++ b/components/tv/TVFocusablePoster.tsx
@@ -10,6 +10,7 @@ import {
export interface TVFocusablePosterProps {
children: React.ReactNode;
onPress: () => void;
+ onLongPress?: () => void;
hasTVPreferredFocus?: boolean;
glowColor?: "white" | "purple";
scaleAmount?: number;
@@ -17,6 +18,8 @@ export interface TVFocusablePosterProps {
onFocus?: () => void;
onBlur?: () => void;
disabled?: boolean;
+ /** When true, the item remains focusable even when disabled (for navigation purposes) */
+ focusableWhenDisabled?: boolean;
/** Setter function for the ref (for focus guide destinations) */
refSetter?: (ref: View | null) => void;
}
@@ -24,6 +27,7 @@ export interface TVFocusablePosterProps {
export const TVFocusablePoster: React.FC = ({
children,
onPress,
+ onLongPress,
hasTVPreferredFocus = false,
glowColor = "white",
scaleAmount = 1.05,
@@ -31,6 +35,7 @@ export const TVFocusablePoster: React.FC = ({
onFocus: onFocusProp,
onBlur: onBlurProp,
disabled = false,
+ focusableWhenDisabled = false,
refSetter,
}) => {
const [focused, setFocused] = useState(false);
@@ -50,6 +55,7 @@ export const TVFocusablePoster: React.FC = ({
{
setFocused(true);
animateTo(scaleAmount);
@@ -62,7 +68,7 @@ export const TVFocusablePoster: React.FC = ({
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
- focusable={!disabled}
+ focusable={!disabled || focusableWhenDisabled}
>
= ({
transform: [{ scale }],
shadowColor,
shadowOffset: { width: 0, height: 0 },
- shadowOpacity: focused ? 0.6 : 0,
- shadowRadius: focused ? 20 : 0,
+ shadowOpacity: focused ? 0.3 : 0,
+ shadowRadius: focused ? 12 : 0,
},
style,
]}
diff --git a/components/tv/TVFocusableProgressBar.tsx b/components/tv/TVFocusableProgressBar.tsx
index e33e4444..8d6a4888 100644
--- a/components/tv/TVFocusableProgressBar.tsx
+++ b/components/tv/TVFocusableProgressBar.tsx
@@ -19,6 +19,8 @@ export interface TVFocusableProgressBarProps {
max: SharedValue;
/** Cache progress value (SharedValue) in milliseconds */
cacheProgress?: SharedValue;
+ /** Chapter positions as percentages (0-100) for tick marks */
+ chapterPositions?: number[];
/** Callback when the progress bar receives focus */
onFocus?: () => void;
/** Callback when the progress bar loses focus */
@@ -41,6 +43,7 @@ export const TVFocusableProgressBar: React.FC =
progress,
max,
cacheProgress,
+ chapterPositions = [],
onFocus,
onBlur,
refSetter,
@@ -81,20 +84,36 @@ export const TVFocusableProgressBar: React.FC =
focused && styles.animatedContainerFocused,
]}
>
-
- {cacheProgress && (
+
+
+ {cacheProgress && (
+
+ )}
+
+ {/* Chapter markers - positioned outside track to extend above */}
+ {chapterPositions.length > 0 && (
+
+ {chapterPositions.map((position, index) => (
+
+ ))}
+
)}
-
@@ -121,6 +140,10 @@ const styles = StyleSheet.create({
shadowOpacity: 0.5,
shadowRadius: 12,
},
+ progressTrackWrapper: {
+ position: "relative",
+ height: PROGRESS_BAR_HEIGHT,
+ },
progressTrack: {
height: PROGRESS_BAR_HEIGHT,
backgroundColor: "rgba(255,255,255,0.2)",
@@ -147,4 +170,20 @@ const styles = StyleSheet.create({
backgroundColor: "#fff",
borderRadius: 8,
},
+ chapterMarkersContainer: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ chapterMarker: {
+ position: "absolute",
+ width: 2,
+ height: PROGRESS_BAR_HEIGHT + 5,
+ bottom: 0,
+ backgroundColor: "rgba(255, 255, 255, 0.6)",
+ borderRadius: 1,
+ transform: [{ translateX: -1 }],
+ },
});
diff --git a/components/tv/TVHorizontalList.tsx b/components/tv/TVHorizontalList.tsx
new file mode 100644
index 00000000..87a2db73
--- /dev/null
+++ b/components/tv/TVHorizontalList.tsx
@@ -0,0 +1,221 @@
+import React, { useCallback } from "react";
+import { FlatList, ScrollView, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+interface TVHorizontalListProps {
+ /** Data items to render */
+ data: T[];
+ /** Unique key extractor */
+ keyExtractor: (item: T, index: number) => string;
+ /** Render function for each item */
+ renderItem: (info: { item: T; index: number }) => React.ReactElement | null;
+ /** Optional section title */
+ title?: string;
+ /** Text to show when data array is empty */
+ emptyText?: string;
+ /** Whether to use FlatList (for large/infinite lists) or ScrollView (for small lists) */
+ useFlatList?: boolean;
+ /** Called when end is reached (only for FlatList) */
+ onEndReached?: () => void;
+ /** Ref for the scroll view */
+ scrollViewRef?: React.RefObject | null>;
+ /** Footer component (only for FlatList) */
+ ListFooterComponent?: React.ReactElement | null;
+ /** Whether this is the first section (for initial focus) */
+ isFirstSection?: boolean;
+ /** Loading state */
+ isLoading?: boolean;
+ /** Skeleton item count when loading */
+ skeletonCount?: number;
+ /** Skeleton render function */
+ renderSkeleton?: () => React.ReactElement;
+ /**
+ * Custom horizontal padding (overrides default sizes.padding.scale).
+ * Use this when the list needs to extend beyond its parent's padding.
+ * The list will use negative margin to extend beyond the parent,
+ * then add this padding inside to align content properly.
+ */
+ horizontalPadding?: number;
+}
+
+/**
+ * TVHorizontalList - A unified horizontal list component for TV.
+ *
+ * Provides consistent spacing and layout for horizontal lists:
+ * - Uses `sizes.gaps.item` (24px default) for gap between items
+ * - Uses `sizes.padding.scale` (20px default) for padding to accommodate focus scale
+ * - Supports both ScrollView (small lists) and FlatList (large/infinite lists)
+ */
+export function TVHorizontalList({
+ data,
+ keyExtractor,
+ renderItem,
+ title,
+ emptyText,
+ useFlatList = false,
+ onEndReached,
+ scrollViewRef,
+ ListFooterComponent,
+ isLoading = false,
+ skeletonCount = 5,
+ renderSkeleton,
+ horizontalPadding,
+}: TVHorizontalListProps) {
+ const sizes = useScaledTVSizes();
+ const typography = useScaledTVTypography();
+
+ // Use custom horizontal padding if provided, otherwise use default scale padding
+ const effectiveHorizontalPadding = horizontalPadding ?? sizes.padding.scale;
+ // Apply negative margin when using custom padding to extend beyond parent
+ const marginHorizontal = horizontalPadding ? -horizontalPadding : 0;
+
+ // Wrap renderItem to add consistent gap
+ const renderItemWithGap = useCallback(
+ ({ item, index }: { item: T; index: number }) => {
+ const isLast = index === data.length - 1;
+ return (
+
+ {renderItem({ item, index })}
+
+ );
+ },
+ [data.length, renderItem, sizes.gaps.item],
+ );
+
+ // Empty state
+ if (!isLoading && data.length === 0 && emptyText) {
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {emptyText}
+
+
+ );
+ }
+
+ // Loading state
+ if (isLoading && renderSkeleton) {
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {Array.from({ length: skeletonCount }).map((_, i) => (
+ {renderSkeleton()}
+ ))}
+
+
+ );
+ }
+
+ const contentContainerStyle = {
+ paddingHorizontal: effectiveHorizontalPadding,
+ paddingVertical: sizes.padding.scale,
+ };
+
+ const listStyle = {
+ overflow: "visible" as const,
+ marginHorizontal,
+ };
+
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {useFlatList ? (
+ >}
+ horizontal
+ data={data}
+ keyExtractor={keyExtractor}
+ renderItem={renderItemWithGap}
+ showsHorizontalScrollIndicator={false}
+ removeClippedSubviews={false}
+ style={listStyle}
+ contentContainerStyle={contentContainerStyle}
+ onEndReached={onEndReached}
+ onEndReachedThreshold={0.5}
+ initialNumToRender={5}
+ maxToRenderPerBatch={3}
+ windowSize={5}
+ maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
+ ListFooterComponent={ListFooterComponent}
+ />
+ ) : (
+ }
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ style={listStyle}
+ contentContainerStyle={contentContainerStyle}
+ >
+ {data.map((item, index) => (
+
+ {renderItem({ item, index })}
+
+ ))}
+ {ListFooterComponent}
+
+ )}
+
+ );
+}
diff --git a/components/tv/TVItemCardText.tsx b/components/tv/TVItemCardText.tsx
new file mode 100644
index 00000000..fe507704
--- /dev/null
+++ b/components/tv/TVItemCardText.tsx
@@ -0,0 +1,33 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import React from "react";
+import { View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+export interface TVItemCardTextProps {
+ item: BaseItemDto;
+}
+
+export const TVItemCardText: React.FC = ({ item }) => {
+ const typography = useScaledTVTypography();
+
+ return (
+
+
+ {item.Name}
+
+
+ {item.ProductionYear}
+
+
+ );
+};
diff --git a/components/tv/TVLanguageCard.tsx b/components/tv/TVLanguageCard.tsx
index 24e3ec84..7b4b712c 100644
--- a/components/tv/TVLanguageCard.tsx
+++ b/components/tv/TVLanguageCard.tsx
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVLanguageCardProps {
@@ -15,6 +15,8 @@ export interface TVLanguageCardProps {
export const TVLanguageCard = React.forwardRef(
({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
+ const typography = useScaledTVTypography();
+ const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -72,26 +74,27 @@ export const TVLanguageCard = React.forwardRef(
},
);
-const styles = StyleSheet.create({
- languageCard: {
- width: 120,
- height: 60,
- borderRadius: 12,
- justifyContent: "center",
- alignItems: "center",
- paddingHorizontal: 12,
- },
- languageCardText: {
- fontSize: TVTypography.callout,
- fontWeight: "500",
- },
- languageCardCode: {
- fontSize: TVTypography.callout,
- marginTop: 2,
- },
- checkmark: {
- position: "absolute",
- top: 8,
- right: 8,
- },
-});
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ languageCard: {
+ width: 120,
+ height: 60,
+ borderRadius: 12,
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: 12,
+ },
+ languageCardText: {
+ fontSize: typography.callout,
+ fontWeight: "500",
+ },
+ languageCardCode: {
+ fontSize: typography.callout,
+ marginTop: 2,
+ },
+ checkmark: {
+ position: "absolute",
+ top: 8,
+ right: 8,
+ },
+ });
diff --git a/components/tv/TVMetadataBadges.tsx b/components/tv/TVMetadataBadges.tsx
index b2ccab2e..4698644e 100644
--- a/components/tv/TVMetadataBadges.tsx
+++ b/components/tv/TVMetadataBadges.tsx
@@ -3,7 +3,7 @@ import React from "react";
import { View } from "react-native";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVMetadataBadgesProps {
year?: number | null;
@@ -14,6 +14,8 @@ export interface TVMetadataBadgesProps {
export const TVMetadataBadges: React.FC = React.memo(
({ year, duration, officialRating, communityRating }) => {
+ const typography = useScaledTVTypography();
+
return (
= React.memo(
}}
>
{year != null && (
-
+
{year}
)}
{duration && (
-
+
{duration}
)}
diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx
index 2030d109..72440f97 100644
--- a/components/tv/TVNextEpisodeCountdown.tsx
+++ b/components/tv/TVNextEpisodeCountdown.tsx
@@ -1,9 +1,15 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { BlurView } from "expo-blur";
-import { type FC, useEffect, useRef } from "react";
+import { type FC, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
-import { Image, StyleSheet, View } from "react-native";
+import {
+ Image,
+ Pressable,
+ Animated as RNAnimated,
+ StyleSheet,
+ View,
+} from "react-native";
import Animated, {
cancelAnimation,
Easing,
@@ -13,8 +19,9 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVNextEpisodeCountdownProps {
nextItem: BaseItemDto;
@@ -22,18 +29,34 @@ export interface TVNextEpisodeCountdownProps {
show: boolean;
isPlaying: boolean;
onFinish: () => void;
+ /** Called when user presses the card to skip to next episode */
+ onPlayNext?: () => void;
+ /** Whether controls are visible - affects card position */
+ controlsVisible?: boolean;
}
+// Position constants
+const BOTTOM_WITH_CONTROLS = 300;
+const BOTTOM_WITHOUT_CONTROLS = 120;
+
export const TVNextEpisodeCountdown: FC = ({
nextItem,
api,
show,
isPlaying,
onFinish,
+ onPlayNext,
+ controlsVisible = false,
}) => {
+ const typography = useScaledTVTypography();
const { t } = useTranslation();
const progress = useSharedValue(0);
const onFinishRef = useRef(onFinish);
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.05,
+ duration: 120,
+ });
onFinishRef.current = onFinish;
@@ -44,118 +67,172 @@ export const TVNextEpisodeCountdown: FC = ({
quality: 80,
});
+ // Animated position based on controls visibility
+ const bottomPosition = useSharedValue(
+ controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS,
+ );
+
useEffect(() => {
- if (show && isPlaying) {
- progress.value = 0;
- progress.value = withTiming(
- 1,
- {
- duration: 8000,
- easing: Easing.linear,
- },
- (finished) => {
- if (finished && onFinishRef.current) {
- runOnJS(onFinishRef.current)();
- }
- },
- );
- } else {
+ const target = controlsVisible
+ ? BOTTOM_WITH_CONTROLS
+ : BOTTOM_WITHOUT_CONTROLS;
+ bottomPosition.value = withTiming(target, {
+ duration: 300,
+ easing: Easing.out(Easing.quad),
+ });
+ }, [controlsVisible, bottomPosition]);
+
+ const containerAnimatedStyle = useAnimatedStyle(() => ({
+ bottom: bottomPosition.value,
+ }));
+
+ // Progress animation - pause/resume without resetting
+ const prevShowRef = useRef(false);
+
+ useEffect(() => {
+ const justStartedShowing = show && !prevShowRef.current;
+ prevShowRef.current = show;
+
+ if (!show) {
cancelAnimation(progress);
progress.value = 0;
+ return;
}
+
+ if (justStartedShowing) {
+ progress.value = 0;
+ }
+
+ if (!isPlaying) {
+ cancelAnimation(progress);
+ return;
+ }
+
+ // Resume from current position
+ const remainingDuration = (1 - progress.value) * 8000;
+ progress.value = withTiming(
+ 1,
+ { duration: remainingDuration, easing: Easing.linear },
+ (finished) => {
+ if (finished) {
+ runOnJS(onFinishRef.current)();
+ }
+ },
+ );
}, [show, isPlaying, progress]);
const progressStyle = useAnimatedStyle(() => ({
width: `${progress.value * 100}%`,
}));
+ const styles = useMemo(() => createStyles(typography), [typography]);
+
if (!show) return null;
return (
-
-
-
- {imageUrl && (
-
- )}
+
+
+
+
+
+ {imageUrl && (
+
+ )}
-
- {t("player.next_episode")}
+
+ {t("player.next_episode")}
-
- {nextItem.SeriesName}
-
+
+ {nextItem.SeriesName}
+
-
- S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
- {nextItem.Name}
-
+
+ S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
+ {nextItem.Name}
+
-
-
+
+
+
+
-
-
-
-
+
+
+
+
);
};
-const styles = StyleSheet.create({
- container: {
- position: "absolute",
- bottom: 180,
- right: 80,
- zIndex: 100,
- },
- blur: {
- borderRadius: 16,
- overflow: "hidden",
- },
- innerContainer: {
- flexDirection: "row",
- alignItems: "stretch",
- },
- thumbnail: {
- width: 180,
- backgroundColor: "rgba(0,0,0,0.3)",
- },
- content: {
- padding: 16,
- justifyContent: "center",
- width: 280,
- },
- label: {
- fontSize: TVTypography.callout,
- color: "rgba(255,255,255,0.5)",
- textTransform: "uppercase",
- letterSpacing: 1,
- marginBottom: 4,
- },
- seriesName: {
- fontSize: TVTypography.callout,
- color: "rgba(255,255,255,0.7)",
- marginBottom: 2,
- },
- episodeInfo: {
- fontSize: TVTypography.body,
- color: "#fff",
- fontWeight: "600",
- marginBottom: 12,
- },
- progressContainer: {
- height: 4,
- backgroundColor: "rgba(255,255,255,0.2)",
- borderRadius: 2,
- overflow: "hidden",
- },
- progressBar: {
- height: "100%",
- backgroundColor: "#fff",
- borderRadius: 2,
- },
-});
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ container: {
+ position: "absolute",
+ right: 80,
+ zIndex: 100,
+ },
+ focusedCard: {
+ shadowColor: "#fff",
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.6,
+ shadowRadius: 16,
+ },
+ blur: {
+ borderRadius: 16,
+ overflow: "hidden",
+ },
+ innerContainer: {
+ flexDirection: "row",
+ alignItems: "stretch",
+ },
+ thumbnail: {
+ width: 180,
+ backgroundColor: "rgba(0,0,0,0.3)",
+ },
+ content: {
+ padding: 16,
+ justifyContent: "center",
+ width: 280,
+ },
+ label: {
+ fontSize: typography.callout,
+ color: "rgba(255,255,255,0.5)",
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ marginBottom: 4,
+ },
+ seriesName: {
+ fontSize: typography.callout,
+ color: "rgba(255,255,255,0.7)",
+ marginBottom: 2,
+ },
+ episodeInfo: {
+ fontSize: typography.body,
+ color: "#fff",
+ fontWeight: "600",
+ marginBottom: 12,
+ },
+ progressContainer: {
+ height: 4,
+ backgroundColor: "rgba(255,255,255,0.2)",
+ borderRadius: 2,
+ overflow: "hidden",
+ },
+ progressBar: {
+ height: "100%",
+ backgroundColor: "#fff",
+ borderRadius: 2,
+ },
+ });
diff --git a/components/tv/TVOptionButton.tsx b/components/tv/TVOptionButton.tsx
index 342caac9..562bd634 100644
--- a/components/tv/TVOptionButton.tsx
+++ b/components/tv/TVOptionButton.tsx
@@ -2,7 +2,7 @@ import { BlurView } from "expo-blur";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVOptionButtonProps {
@@ -10,10 +10,12 @@ export interface TVOptionButtonProps {
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
+ maxWidth?: number;
}
export const TVOptionButton = React.forwardRef(
- ({ label, value, onPress, hasTVPreferredFocus }, ref) => {
+ ({ label, value, onPress, hasTVPreferredFocus, maxWidth }, ref) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
@@ -46,21 +48,24 @@ export const TVOptionButton = React.forwardRef(
flexDirection: "row",
alignItems: "center",
gap: 8,
+ maxWidth,
}}
>
{label}
@@ -74,6 +79,7 @@ export const TVOptionButton = React.forwardRef(
style={{
borderRadius: 8,
overflow: "hidden",
+ maxWidth,
}}
>
(
>
{label}
diff --git a/components/tv/TVOptionCard.tsx b/components/tv/TVOptionCard.tsx
index ee36fc64..a8e83890 100644
--- a/components/tv/TVOptionCard.tsx
+++ b/components/tv/TVOptionCard.tsx
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVOptionCardProps {
@@ -28,6 +28,7 @@ export const TVOptionCard = React.forwardRef(
},
ref,
) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -59,7 +60,7 @@ export const TVOptionCard = React.forwardRef(
>
(
{sublabel && (
({
cardWidth = 160,
cardHeight = 75,
}: TVOptionSelectorProps) => {
+ const typography = useScaledTVTypography();
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef(null);
@@ -91,6 +92,8 @@ export const TVOptionSelector = ({
}
}, [isReady]);
+ const styles = useMemo(() => createStyles(typography), [typography]);
+
if (!visible) return null;
return (
@@ -151,50 +154,51 @@ export const TVOptionSelector = ({
);
};
-const styles = StyleSheet.create({
- overlay: {
- position: "absolute",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- justifyContent: "flex-end",
- zIndex: 1000,
- },
- sheetContainer: {
- width: "100%",
- },
- blurContainer: {
- borderTopLeftRadius: 24,
- borderTopRightRadius: 24,
- overflow: "hidden",
- },
- content: {
- paddingTop: 24,
- paddingBottom: 50,
- overflow: "visible",
- },
- title: {
- fontSize: TVTypography.callout,
- fontWeight: "500",
- color: "rgba(255,255,255,0.6)",
- marginBottom: 16,
- paddingHorizontal: 48,
- textTransform: "uppercase",
- letterSpacing: 1,
- },
- scrollView: {
- overflow: "visible",
- },
- scrollContent: {
- paddingHorizontal: 48,
- paddingVertical: 20,
- gap: 12,
- },
- cancelButtonContainer: {
- marginTop: 16,
- paddingHorizontal: 48,
- alignItems: "flex-start",
- },
-});
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ overlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ zIndex: 1000,
+ },
+ sheetContainer: {
+ width: "100%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: 24,
+ paddingBottom: 50,
+ overflow: "visible",
+ },
+ title: {
+ fontSize: typography.callout,
+ fontWeight: "500",
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: 16,
+ paddingHorizontal: 48,
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ },
+ scrollView: {
+ overflow: "visible",
+ },
+ scrollContent: {
+ paddingHorizontal: 48,
+ paddingVertical: 20,
+ gap: 12,
+ },
+ cancelButtonContainer: {
+ marginTop: 16,
+ paddingHorizontal: 48,
+ alignItems: "flex-start",
+ },
+ });
diff --git a/components/tv/TVPlayedButton.tsx b/components/tv/TVPlayedButton.tsx
new file mode 100644
index 00000000..8ab8e4bb
--- /dev/null
+++ b/components/tv/TVPlayedButton.tsx
@@ -0,0 +1,33 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import React from "react";
+import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
+import { TVButton } from "./TVButton";
+
+export interface TVPlayedButtonProps {
+ item: BaseItemDto;
+ disabled?: boolean;
+}
+
+export const TVPlayedButton: React.FC = ({
+ item,
+ disabled,
+}) => {
+ const isPlayed = item.UserData?.Played ?? false;
+ const toggle = useMarkAsPlayed([item]);
+
+ return (
+ toggle(!isPlayed)}
+ variant='glass'
+ square
+ disabled={disabled}
+ >
+
+
+ );
+};
diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx
new file mode 100644
index 00000000..6cb4fa82
--- /dev/null
+++ b/components/tv/TVPosterCard.tsx
@@ -0,0 +1,579 @@
+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 React, { useMemo, useRef, useState } from "react";
+import {
+ Animated,
+ Easing,
+ Pressable,
+ View,
+ type ViewStyle,
+} from "react-native";
+import { ProgressBar } from "@/components/common/ProgressBar";
+import { Text } from "@/components/common/Text";
+import { WatchedIndicator } from "@/components/WatchedIndicator";
+import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import {
+ GlassPosterView,
+ isGlassEffectAvailable,
+} from "@/modules/glass-poster";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { runtimeTicksToMinutes } from "@/utils/time";
+
+export interface TVPosterCardProps {
+ item: BaseItemDto;
+ /** Poster orientation: vertical = 10:15 (portrait), horizontal = 16:9 (landscape) */
+ orientation?: "vertical" | "horizontal";
+ /** Show text below the poster (title, subtitle) - default: true */
+ showText?: boolean;
+ /** Show progress bar - default: true for items with progress */
+ showProgress?: boolean;
+ /** Show watched indicator - default: true */
+ showWatchedIndicator?: boolean;
+
+ // Focus props
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ /** When true, the item remains focusable even when disabled (for navigation purposes) */
+ focusableWhenDisabled?: boolean;
+
+ /** Shows a "Now Playing" badge on the card */
+ isCurrent?: boolean;
+ /** Show a play button overlay */
+ showPlayButton?: boolean;
+
+ // Handlers
+ onPress: () => void;
+ onLongPress?: () => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+
+ /** Setter function for the ref (for focus guide destinations) */
+ refSetter?: (ref: View | null) => void;
+
+ /** Custom width - overrides default based on orientation */
+ width?: number;
+
+ /** Custom style for the outer container */
+ style?: ViewStyle;
+
+ /** Glow color for focus state */
+ glowColor?: "white" | "purple";
+
+ /** Scale amount for focus animation */
+ scaleAmount?: number;
+
+ /** Custom image URL getter - if not provided, uses smart URL logic */
+ imageUrlGetter?: (item: BaseItemDto) => string | undefined;
+}
+
+/**
+ * TVPosterCard - Unified poster component for TV interface.
+ *
+ * Combines image rendering, focus handling, and text display into a single component.
+ * Supports both portrait (10:15) and landscape (16:9) orientations.
+ *
+ * Features:
+ * - Glass effect on tvOS 26+ with fallback
+ * - Focus handling with scale animation and glow
+ * - Progress bar and watched indicator
+ * - Smart subtitle text based on item type
+ * - "Now Playing" badge for current items
+ */
+export const TVPosterCard: React.FC = ({
+ item,
+ orientation = "vertical",
+ showText = true,
+ showProgress = true,
+ showWatchedIndicator = true,
+ hasTVPreferredFocus = false,
+ disabled = false,
+ focusableWhenDisabled = false,
+ isCurrent = false,
+ showPlayButton = false,
+ onPress,
+ onLongPress,
+ onFocus: onFocusProp,
+ onBlur: onBlurProp,
+ refSetter,
+ width: customWidth,
+ style,
+ glowColor = "white",
+ scaleAmount = 1.05,
+ imageUrlGetter,
+}) => {
+ const api = useAtomValue(apiAtom);
+ const posterSizes = useScaledTVPosterSizes();
+ const typography = useScaledTVTypography();
+
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ // Determine width based on orientation
+ const width = useMemo(() => {
+ if (customWidth) return customWidth;
+ return orientation === "horizontal"
+ ? posterSizes.episode
+ : posterSizes.poster;
+ }, [customWidth, orientation, posterSizes]);
+
+ const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
+
+ // Smart image URL selection
+ const imageUrl = useMemo(() => {
+ // Use custom getter if provided
+ if (imageUrlGetter) {
+ return imageUrlGetter(item) ?? null;
+ }
+
+ if (!api) return null;
+
+ // Horizontal orientation: prefer thumbs/backdrops for landscape images
+ if (orientation === "horizontal") {
+ // Episode: prefer series thumb image for consistent look (like hero section)
+ if (item.Type === "Episode") {
+ // First try parent/series thumb (horizontal series artwork)
+ if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
+ return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
+ }
+ // Fall back to episode's own primary image
+ if (item.ImageTags?.Primary) {
+ return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
+ }
+ // Last resort: try primary without tag
+ return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
+ }
+
+ // Movie/Series/Program: prefer thumb over primary
+ if (item.ImageTags?.Thumb) {
+ return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags.Thumb}`;
+ }
+ return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
+ }
+
+ // Vertical orientation: use primary image
+ // For episodes, get the series primary image
+ if (
+ item.Type === "Episode" &&
+ item.SeriesId &&
+ item.SeriesPrimaryImageTag
+ ) {
+ return `${api.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${width * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`;
+ }
+
+ return getPrimaryImageUrl({
+ api,
+ item,
+ width: width * 2, // 2x for quality on large screens
+ });
+ }, [api, item, orientation, width, imageUrlGetter]);
+
+ // Progress calculation
+ const progress = useMemo(() => {
+ if (!showProgress) return 0;
+
+ if (item.Type === "Program") {
+ if (!item.StartDate || !item.EndDate) return 0;
+ const startDate = new Date(item.StartDate);
+ const endDate = new Date(item.EndDate);
+ const now = new Date();
+ const total = endDate.getTime() - startDate.getTime();
+ if (total <= 0) return 0;
+ const elapsed = now.getTime() - startDate.getTime();
+ return (elapsed / total) * 100;
+ }
+ return item.UserData?.PlayedPercentage || 0;
+ }, [item, showProgress]);
+
+ const isWatched = showWatchedIndicator && item.UserData?.Played === true;
+
+ // Blurhash for placeholder
+ const blurhash = useMemo(() => {
+ const key = item.ImageTags?.Primary as string;
+ return item.ImageBlurHashes?.Primary?.[key];
+ }, [item]);
+
+ // Glass effect availability
+ const useGlass = isGlassEffectAvailable();
+
+ // Focus animation
+ const animateTo = (value: number) =>
+ Animated.timing(scale, {
+ toValue: value,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7";
+
+ // Text rendering helpers
+ const renderSubtitle = () => {
+ if (!showText) return null;
+
+ // Episode: S#:E# • duration
+ if (item.Type === "Episode") {
+ const season = item.ParentIndexNumber;
+ const ep = item.IndexNumber;
+ const episodeLabel =
+ season !== undefined && ep !== undefined ? `S${season}:E${ep}` : null;
+ const duration = item.RunTimeTicks
+ ? runtimeTicksToMinutes(item.RunTimeTicks)
+ : null;
+
+ return (
+
+ {episodeLabel && (
+
+ {episodeLabel}
+
+ )}
+ {duration && (
+ <>
+
+ •
+
+
+ {duration}
+
+ >
+ )}
+
+ );
+ }
+
+ // Program: channel name
+ if (item.Type === "Program" && item.ChannelName) {
+ return (
+
+ {item.ChannelName}
+
+ );
+ }
+
+ // MusicAlbum: artist
+ if (item.Type === "MusicAlbum") {
+ const artist = item.AlbumArtist || item.Artists?.join(", ");
+ if (artist) {
+ return (
+
+ {artist}
+
+ );
+ }
+ }
+
+ // Audio: artist
+ if (item.Type === "Audio") {
+ const artist = item.Artists?.join(", ") || item.AlbumArtist;
+ if (artist) {
+ return (
+
+ {artist}
+
+ );
+ }
+ }
+
+ // Playlist: track count
+ if (item.Type === "Playlist" && item.ChildCount) {
+ return (
+
+ {item.ChildCount} tracks
+
+ );
+ }
+
+ // Default: production year
+ if (item.ProductionYear) {
+ return (
+
+ {item.ProductionYear}
+
+ );
+ }
+
+ return null;
+ };
+
+ // Now Playing badge component
+ const NowPlayingBadge = isCurrent ? (
+
+
+
+ Now Playing
+
+
+ ) : null;
+
+ // Play button overlay component
+ const PlayButtonOverlay = showPlayButton ? (
+
+
+
+ ) : null;
+
+ // Render poster image
+ const renderPosterImage = () => {
+ // Empty placeholder when no URL
+ if (!imageUrl) {
+ return (
+
+ );
+ }
+
+ // Glass effect rendering (tvOS 26+)
+ if (useGlass) {
+ return (
+
+
+ {PlayButtonOverlay}
+ {NowPlayingBadge}
+
+ );
+ }
+
+ // Fallback rendering for older tvOS versions
+ return (
+
+
+ {PlayButtonOverlay}
+ {NowPlayingBadge}
+
+
+
+ );
+ };
+
+ // Render title based on item type
+ const renderTitle = () => {
+ if (!showText) return null;
+
+ // Episode: show episode name as title
+ if (item.Type === "Episode") {
+ return (
+
+ {item.Name}
+
+ );
+ }
+
+ // MusicArtist: centered text
+ if (item.Type === "MusicArtist") {
+ return (
+
+ {item.Name}
+
+ );
+ }
+
+ // Default: show name
+ return (
+
+ {item.Name}
+
+ );
+ };
+
+ return (
+
+ {
+ setFocused(true);
+ // Only animate scale when not using glass effect (glass handles its own focus visual)
+ if (!useGlass) {
+ animateTo(scaleAmount);
+ }
+ onFocusProp?.();
+ }}
+ onBlur={() => {
+ setFocused(false);
+ if (!useGlass) {
+ animateTo(1);
+ }
+ onBlurProp?.();
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
+ disabled={disabled && !focusableWhenDisabled}
+ focusable={!disabled || focusableWhenDisabled}
+ >
+
+ {renderPosterImage()}
+
+
+
+ {/* Text below poster */}
+ {showText && (
+
+ {item.Type === "Episode" ? (
+ <>
+ {renderSubtitle()}
+ {renderTitle()}
+ >
+ ) : (
+ <>
+ {renderTitle()}
+ {renderSubtitle()}
+ >
+ )}
+
+ )}
+
+ );
+};
diff --git a/components/tv/TVSeriesNavigation.tsx b/components/tv/TVSeriesNavigation.tsx
index 6e088664..5414dde7 100644
--- a/components/tv/TVSeriesNavigation.tsx
+++ b/components/tv/TVSeriesNavigation.tsx
@@ -3,7 +3,8 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
export interface TVSeriesNavigationProps {
@@ -16,6 +17,8 @@ export interface TVSeriesNavigationProps {
export const TVSeriesNavigation: React.FC = React.memo(
({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => {
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
const { t } = useTranslation();
// Only show for episodes with a series
@@ -24,13 +27,14 @@ export const TVSeriesNavigation: React.FC = React.memo(
}
return (
-
+
{t("item_card.from_this_series") || "From this Series"}
@@ -38,11 +42,14 @@ export const TVSeriesNavigation: React.FC = React.memo(
{/* Series card */}
diff --git a/components/tv/TVSeriesSeasonCard.tsx b/components/tv/TVSeriesSeasonCard.tsx
index eb69f7f8..8ec93d8f 100644
--- a/components/tv/TVSeriesSeasonCard.tsx
+++ b/components/tv/TVSeriesSeasonCard.tsx
@@ -1,10 +1,14 @@
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
-import React from "react";
-import { Animated, Pressable, View } from "react-native";
+import React, { useRef, useState } from "react";
+import { Animated, Easing, Platform, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
-import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import {
+ GlassPosterView,
+ isGlassEffectAvailable,
+} from "@/modules/glass-poster";
export interface TVSeriesSeasonCardProps {
title: string;
@@ -12,6 +16,8 @@ export interface TVSeriesSeasonCardProps {
imageUrl: string | null;
onPress: () => void;
hasTVPreferredFocus?: boolean;
+ /** Setter function for the ref (for focus guide destinations) */
+ refSetter?: (ref: View | null) => void;
}
export const TVSeriesSeasonCard: React.FC = ({
@@ -20,67 +26,114 @@ export const TVSeriesSeasonCard: React.FC = ({
imageUrl,
onPress,
hasTVPreferredFocus,
+ refSetter,
}) => {
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.05 });
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
+ const [focused, setFocused] = useState(false);
+
+ // Check if glass effect is available (tvOS 26+)
+ const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
+
+ // Scale animation for focus (only used when NOT using glass effect)
+ const scale = useRef(new Animated.Value(1)).current;
+ const animateTo = (value: number) =>
+ Animated.timing(scale, {
+ toValue: value,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ const renderPoster = () => {
+ if (useGlass) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+ };
return (
-
-
+ {
+ setFocused(true);
+ // Only animate scale when not using glass effect (glass handles its own focus visual)
+ if (!useGlass) {
+ animateTo(1.05);
+ }
+ }}
+ onBlur={() => {
+ setFocused(false);
+ if (!useGlass) {
+ animateTo(1);
+ }
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
>
-
- {imageUrl ? (
-
- ) : (
-
-
-
- )}
-
+ {renderPoster()}
+
+
+
@@ -90,18 +143,16 @@ export const TVSeriesSeasonCard: React.FC = ({
{subtitle && (
{subtitle}
)}
-
-
+
+
);
};
diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx
new file mode 100644
index 00000000..3e53d0f0
--- /dev/null
+++ b/components/tv/TVSkipSegmentCard.tsx
@@ -0,0 +1,117 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { FC } from "react";
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native";
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVSkipSegmentCardProps {
+ show: boolean;
+ onPress: () => void;
+ type: "intro" | "credits";
+ /** Whether controls are visible - affects card position */
+ controlsVisible?: boolean;
+}
+
+// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
+const BOTTOM_WITH_CONTROLS = 300;
+const BOTTOM_WITHOUT_CONTROLS = 120;
+
+export const TVSkipSegmentCard: FC = ({
+ show,
+ onPress,
+ type,
+ controlsVisible = false,
+}) => {
+ const { t } = useTranslation();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.1,
+ duration: 120,
+ });
+
+ // Animated position based on controls visibility
+ const bottomPosition = useSharedValue(
+ controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS,
+ );
+
+ useEffect(() => {
+ const target = controlsVisible
+ ? BOTTOM_WITH_CONTROLS
+ : BOTTOM_WITHOUT_CONTROLS;
+ bottomPosition.value = withTiming(target, {
+ duration: 300,
+ easing: Easing.out(Easing.quad),
+ });
+ }, [controlsVisible, bottomPosition]);
+
+ const containerAnimatedStyle = useAnimatedStyle(() => ({
+ bottom: bottomPosition.value,
+ }));
+
+ const labelText =
+ type === "intro" ? t("player.skip_intro") : t("player.skip_credits");
+
+ if (!show) return null;
+
+ return (
+
+
+
+
+ {labelText}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ right: 80,
+ zIndex: 100,
+ },
+ button: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingVertical: 10,
+ paddingHorizontal: 18,
+ borderRadius: 12,
+ borderWidth: 2,
+ gap: 8,
+ },
+ label: {
+ fontSize: 20,
+ color: "#fff",
+ fontWeight: "600",
+ },
+});
diff --git a/components/tv/TVSubtitleResultCard.tsx b/components/tv/TVSubtitleResultCard.tsx
index ed631ab3..c7633f64 100644
--- a/components/tv/TVSubtitleResultCard.tsx
+++ b/components/tv/TVSubtitleResultCard.tsx
@@ -8,7 +8,7 @@ import {
View,
} from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
@@ -23,6 +23,8 @@ export const TVSubtitleResultCard = React.forwardRef<
View,
TVSubtitleResultCardProps
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
+ const typography = useScaledTVTypography();
+ const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 });
@@ -197,72 +199,73 @@ export const TVSubtitleResultCard = React.forwardRef<
);
});
-const styles = StyleSheet.create({
- resultCard: {
- width: 220,
- minHeight: 120,
- borderRadius: 14,
- padding: 14,
- borderWidth: 1,
- },
- providerBadge: {
- alignSelf: "flex-start",
- paddingHorizontal: 8,
- paddingVertical: 3,
- borderRadius: 6,
- marginBottom: 8,
- },
- providerText: {
- fontSize: TVTypography.callout,
- fontWeight: "600",
- textTransform: "uppercase",
- letterSpacing: 0.5,
- },
- resultName: {
- fontSize: TVTypography.callout,
- fontWeight: "500",
- marginBottom: 8,
- lineHeight: 18,
- },
- resultMeta: {
- flexDirection: "row",
- alignItems: "center",
- gap: 12,
- marginBottom: 8,
- },
- resultMetaText: {
- fontSize: TVTypography.callout,
- },
- ratingContainer: {
- flexDirection: "row",
- alignItems: "center",
- gap: 3,
- },
- downloadCountContainer: {
- flexDirection: "row",
- alignItems: "center",
- gap: 3,
- },
- flagsContainer: {
- flexDirection: "row",
- gap: 6,
- flexWrap: "wrap",
- },
- flag: {
- paddingHorizontal: 6,
- paddingVertical: 2,
- borderRadius: 4,
- },
- flagText: {
- fontSize: TVTypography.callout,
- fontWeight: "600",
- color: "#fff",
- },
- downloadingOverlay: {
- ...StyleSheet.absoluteFillObject,
- backgroundColor: "rgba(0,0,0,0.5)",
- borderRadius: 14,
- justifyContent: "center",
- alignItems: "center",
- },
-});
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ resultCard: {
+ width: 220,
+ minHeight: 120,
+ borderRadius: 14,
+ padding: 14,
+ borderWidth: 1,
+ },
+ providerBadge: {
+ alignSelf: "flex-start",
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 6,
+ marginBottom: 8,
+ },
+ providerText: {
+ fontSize: typography.callout,
+ fontWeight: "600",
+ textTransform: "uppercase",
+ letterSpacing: 0.5,
+ },
+ resultName: {
+ fontSize: typography.callout,
+ fontWeight: "500",
+ marginBottom: 8,
+ lineHeight: 18,
+ },
+ resultMeta: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ marginBottom: 8,
+ },
+ resultMetaText: {
+ fontSize: typography.callout,
+ },
+ ratingContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 3,
+ },
+ downloadCountContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 3,
+ },
+ flagsContainer: {
+ flexDirection: "row",
+ gap: 6,
+ flexWrap: "wrap",
+ },
+ flag: {
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 4,
+ },
+ flagText: {
+ fontSize: typography.callout,
+ fontWeight: "600",
+ color: "#fff",
+ },
+ downloadingOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ borderRadius: 14,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ });
diff --git a/components/tv/TVTabButton.tsx b/components/tv/TVTabButton.tsx
index c421f573..d545985b 100644
--- a/components/tv/TVTabButton.tsx
+++ b/components/tv/TVTabButton.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVTabButtonProps {
@@ -21,6 +21,7 @@ export const TVTabButton: React.FC = ({
switchOnFocus = false,
disabled = false,
}) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
@@ -56,7 +57,7 @@ export const TVTabButton: React.FC = ({
>
= React.memo(
({ mediaStreams }) => {
+ const typography = useScaledTVTypography();
const { t } = useTranslation();
const videoStream = mediaStreams.find((s) => s.Type === "Video");
@@ -24,7 +25,7 @@ export const TVTechnicalDetails: React.FC = React.memo(
= React.memo(
- Video
+ {t("common.video")}
-
+
{videoStream.DisplayTitle ||
`${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`}
@@ -56,16 +57,16 @@ export const TVTechnicalDetails: React.FC = React.memo(
- Audio
+ {t("common.audio")}
-
+
{audioStream.DisplayTitle ||
`${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`}
diff --git a/components/tv/TVThemeMusicIndicator.tsx b/components/tv/TVThemeMusicIndicator.tsx
new file mode 100644
index 00000000..93be4dfb
--- /dev/null
+++ b/components/tv/TVThemeMusicIndicator.tsx
@@ -0,0 +1,78 @@
+import { Ionicons } from "@expo/vector-icons";
+import React, { useRef, useState } from "react";
+import { Animated, Easing, Pressable, View } from "react-native";
+import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer";
+
+interface TVThemeMusicIndicatorProps {
+ isPlaying: boolean;
+ isMuted: boolean;
+ hasThemeMusic: boolean;
+ onToggleMute: () => void;
+ disabled?: boolean;
+}
+
+export const TVThemeMusicIndicator: React.FC = ({
+ isPlaying,
+ isMuted,
+ hasThemeMusic,
+ onToggleMute,
+ disabled = false,
+}) => {
+ 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();
+
+ if (!hasThemeMusic || !isPlaying) return null;
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.15);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+ {isMuted ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/components/tv/TVTrackCard.tsx b/components/tv/TVTrackCard.tsx
index e1b7106f..7ec27d09 100644
--- a/components/tv/TVTrackCard.tsx
+++ b/components/tv/TVTrackCard.tsx
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVTrackCardProps {
@@ -15,6 +15,8 @@ export interface TVTrackCardProps {
export const TVTrackCard = React.forwardRef(
({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
+ const typography = useScaledTVTypography();
+ const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -77,26 +79,27 @@ export const TVTrackCard = React.forwardRef(
},
);
-const styles = StyleSheet.create({
- trackCard: {
- width: 180,
- height: 80,
- borderRadius: 14,
- justifyContent: "center",
- alignItems: "center",
- paddingHorizontal: 12,
- },
- trackCardText: {
- fontSize: TVTypography.callout,
- textAlign: "center",
- },
- trackCardSublabel: {
- fontSize: TVTypography.callout,
- marginTop: 2,
- },
- checkmark: {
- position: "absolute",
- top: 8,
- right: 8,
- },
-});
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ trackCard: {
+ width: 180,
+ height: 80,
+ borderRadius: 14,
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: 12,
+ },
+ trackCardText: {
+ fontSize: typography.callout,
+ textAlign: "center",
+ },
+ trackCardSublabel: {
+ fontSize: typography.callout,
+ marginTop: 2,
+ },
+ checkmark: {
+ position: "absolute",
+ top: 8,
+ right: 8,
+ },
+ });
diff --git a/components/tv/TVUserCard.tsx b/components/tv/TVUserCard.tsx
new file mode 100644
index 00000000..0f77e213
--- /dev/null
+++ b/components/tv/TVUserCard.tsx
@@ -0,0 +1,174 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import type { AccountSecurityType } from "@/utils/secureCredentials";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVUserCardProps {
+ username: string;
+ securityType: AccountSecurityType;
+ hasTVPreferredFocus?: boolean;
+ isCurrent?: boolean;
+ onPress: () => void;
+}
+
+export const TVUserCard = React.forwardRef(
+ (
+ {
+ username,
+ securityType,
+ hasTVPreferredFocus = false,
+ isCurrent = false,
+ onPress,
+ },
+ ref,
+ ) => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: isCurrent ? 1.02 : 1.05 });
+
+ const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
+ switch (securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
+ const getSecurityText = (): string => {
+ switch (securityType) {
+ case "pin":
+ return t("save_account.pin_code");
+ case "password":
+ return t("save_account.password");
+ default:
+ return t("save_account.no_protection");
+ }
+ };
+
+ const getBackgroundColor = () => {
+ if (isCurrent) {
+ return focused ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.04)";
+ }
+ return focused ? "#fff" : "rgba(255,255,255,0.08)";
+ };
+
+ const getTextColor = () => {
+ if (isCurrent) {
+ return "rgba(255,255,255,0.4)";
+ }
+ return focused ? "#000" : "#fff";
+ };
+
+ const getSecondaryColor = () => {
+ if (isCurrent) {
+ return "rgba(255,255,255,0.25)";
+ }
+ return focused ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.5)";
+ };
+
+ return (
+
+
+ {/* User Avatar */}
+
+
+
+
+ {/* Text column */}
+
+ {/* Username */}
+
+
+ {username}
+
+ {isCurrent && (
+
+ ({t("home.settings.switch_user.current")})
+
+ )}
+
+
+ {/* Security indicator */}
+
+
+
+ {getSecurityText()}
+
+
+
+
+
+ );
+ },
+);
diff --git a/components/tv/hooks/useTVFocusAnimation.ts b/components/tv/hooks/useTVFocusAnimation.ts
index b76d39f0..b3418c8c 100644
--- a/components/tv/hooks/useTVFocusAnimation.ts
+++ b/components/tv/hooks/useTVFocusAnimation.ts
@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from "react";
import { Animated, Easing } from "react-native";
+import { useInactivity } from "@/providers/InactivityProvider";
export interface UseTVFocusAnimationOptions {
scaleAmount?: number;
@@ -24,6 +25,7 @@ export const useTVFocusAnimation = ({
}: UseTVFocusAnimationOptions = {}): UseTVFocusAnimationReturn => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
+ const { resetInactivityTimer } = useInactivity();
const animateTo = useCallback(
(value: number) => {
@@ -40,8 +42,9 @@ export const useTVFocusAnimation = ({
const handleFocus = useCallback(() => {
setFocused(true);
animateTo(scaleAmount);
+ resetInactivityTimer();
onFocus?.();
- }, [animateTo, scaleAmount, onFocus]);
+ }, [animateTo, scaleAmount, resetInactivityTimer, onFocus]);
const handleBlur = useCallback(() => {
setFocused(false);
diff --git a/components/tv/index.ts b/components/tv/index.ts
index 3620945d..a35104eb 100644
--- a/components/tv/index.ts
+++ b/components/tv/index.ts
@@ -25,8 +25,12 @@ export type { TVControlButtonProps } from "./TVControlButton";
export { TVControlButton } from "./TVControlButton";
export type { TVFavoriteButtonProps } from "./TVFavoriteButton";
export { TVFavoriteButton } from "./TVFavoriteButton";
+export type { TVFilterButtonProps } from "./TVFilterButton";
+export { TVFilterButton } from "./TVFilterButton";
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
export { TVFocusablePoster } from "./TVFocusablePoster";
+export type { TVItemCardTextProps } from "./TVItemCardText";
+export { TVItemCardText } from "./TVItemCardText";
export type { TVLanguageCardProps } from "./TVLanguageCard";
export { TVLanguageCard } from "./TVLanguageCard";
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
@@ -39,6 +43,8 @@ export type { TVOptionCardProps } from "./TVOptionCard";
export { TVOptionCard } from "./TVOptionCard";
export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
export { TVOptionSelector } from "./TVOptionSelector";
+export type { TVPlayedButtonProps } from "./TVPlayedButton";
+export { TVPlayedButton } from "./TVPlayedButton";
export type { TVProgressBarProps } from "./TVProgressBar";
export { TVProgressBar } from "./TVProgressBar";
export type { TVRefreshButtonProps } from "./TVRefreshButton";
@@ -47,12 +53,18 @@ export type { TVSeriesNavigationProps } from "./TVSeriesNavigation";
export { TVSeriesNavigation } from "./TVSeriesNavigation";
export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard";
export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
+export type { TVSkipSegmentCardProps } from "./TVSkipSegmentCard";
+export { TVSkipSegmentCard } from "./TVSkipSegmentCard";
export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard";
export { TVSubtitleResultCard } from "./TVSubtitleResultCard";
export type { TVTabButtonProps } from "./TVTabButton";
export { TVTabButton } from "./TVTabButton";
export type { TVTechnicalDetailsProps } from "./TVTechnicalDetails";
export { TVTechnicalDetails } from "./TVTechnicalDetails";
+export { TVThemeMusicIndicator } from "./TVThemeMusicIndicator";
// Subtitle sheet components
export type { TVTrackCardProps } from "./TVTrackCard";
export { TVTrackCard } from "./TVTrackCard";
+// User switching
+export type { TVUserCardProps } from "./TVUserCard";
+export { TVUserCard } from "./TVUserCard";
diff --git a/components/tv/settings/TVLogoutButton.tsx b/components/tv/settings/TVLogoutButton.tsx
index 9df832f1..77a8ddf9 100644
--- a/components/tv/settings/TVLogoutButton.tsx
+++ b/components/tv/settings/TVLogoutButton.tsx
@@ -2,7 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
export interface TVLogoutButtonProps {
@@ -15,6 +15,7 @@ export const TVLogoutButton: React.FC = ({
disabled,
}) => {
const { t } = useTranslation();
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -49,7 +50,7 @@ export const TVLogoutButton: React.FC = ({
>
= ({ title }) => (
-
- {title}
-
-);
+export const TVSectionHeader: React.FC = ({ title }) => {
+ const typography = useScaledTVTypography();
+
+ return (
+
+ {title}
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsOptionButton.tsx b/components/tv/settings/TVSettingsOptionButton.tsx
index 52978caa..0166f99b 100644
--- a/components/tv/settings/TVSettingsOptionButton.tsx
+++ b/components/tv/settings/TVSettingsOptionButton.tsx
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
export interface TVSettingsOptionButtonProps {
@@ -20,6 +20,7 @@ export const TVSettingsOptionButton: React.FC = ({
isFirst,
disabled,
}) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
@@ -46,16 +47,17 @@ export const TVSettingsOptionButton: React.FC = ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
+ opacity: disabled ? 0.4 : 1,
},
]}
>
-
+
{label}
= ({
showChevron = true,
disabled,
}) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
@@ -51,13 +52,13 @@ export const TVSettingsRow: React.FC = ({
},
]}
>
-
+
{label}
= ({
isFirst,
disabled,
}) => {
+ const typography = useScaledTVTypography();
const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 });
const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
@@ -54,7 +55,7 @@ export const TVSettingsStepper: React.FC = ({
focusable={!disabled}
>
-
+
{label}
@@ -89,7 +90,7 @@ export const TVSettingsStepper: React.FC = ({
= ({
secureTextEntry,
disabled,
}) => {
+ const typography = useScaledTVTypography();
const inputRef = useRef(null);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
@@ -56,7 +57,7 @@ export const TVSettingsTextInput: React.FC = ({
>
= ({
autoCapitalize='none'
autoCorrect={false}
style={{
- fontSize: TVTypography.body,
+ fontSize: typography.body,
color: "#FFFFFF",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 8,
diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx
index c50a6518..a2a3e565 100644
--- a/components/tv/settings/TVSettingsToggle.tsx
+++ b/components/tv/settings/TVSettingsToggle.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
export interface TVSettingsToggleProps {
@@ -19,6 +19,7 @@ export const TVSettingsToggle: React.FC = ({
isFirst,
disabled,
}) => {
+ const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
@@ -48,7 +49,7 @@ export const TVSettingsToggle: React.FC = ({
},
]}
>
-
+
{label}
= ({
width: 56,
height: 32,
borderRadius: 16,
- backgroundColor: value ? "#34C759" : "#4B5563",
+ backgroundColor: value ? "#FFFFFF" : "#4B5563",
justifyContent: "center",
paddingHorizontal: 2,
}}
@@ -66,7 +67,7 @@ export const TVSettingsToggle: React.FC = ({
width: 28,
height: 28,
borderRadius: 14,
- backgroundColor: "#FFFFFF",
+ backgroundColor: value ? "#000000" : "#FFFFFF",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>
diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx
index 51abf68c..e4f26492 100644
--- a/components/video-player/controls/BottomControls.tsx
+++ b/components/video-player/controls/BottomControls.tsx
@@ -6,6 +6,7 @@ import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings";
+import { ChapterMarkers } from "./ChapterMarkers";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay";
@@ -57,6 +58,9 @@ interface BottomControlsProps {
minutes: number;
seconds: number;
};
+
+ // Chapter props
+ chapterPositions?: number[];
}
export const BottomControls: FC = ({
@@ -87,6 +91,7 @@ export const BottomControls: FC = ({
trickPlayUrl,
trickplayInfo,
time,
+ chapterPositions = [],
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
@@ -176,6 +181,7 @@ export const BottomControls: FC = ({
height: 10,
justifyContent: "center",
alignItems: "stretch",
+ position: "relative",
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
@@ -212,6 +218,7 @@ export const BottomControls: FC = ({
minimumValue={min}
maximumValue={max}
/>
+
void;
handleSkipBackward: () => void;
handleSkipForward: () => void;
+ // Chapter navigation props
+ hasChapters?: boolean;
+ hasPreviousChapter?: boolean;
+ hasNextChapter?: boolean;
+ goToPreviousChapter?: () => void;
+ goToNextChapter?: () => void;
}
export const CenterControls: FC = ({
@@ -29,6 +35,11 @@ export const CenterControls: FC = ({
togglePlay,
handleSkipBackward,
handleSkipForward,
+ hasChapters = false,
+ hasPreviousChapter = false,
+ hasNextChapter = false,
+ goToPreviousChapter,
+ goToNextChapter,
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
@@ -94,6 +105,20 @@ export const CenterControls: FC = ({
)}
+ {!Platform.isTV && hasChapters && (
+
+
+
+ )}
+
{!isBuffering ? (
@@ -108,6 +133,20 @@ export const CenterControls: FC = ({
+ {!Platform.isTV && hasChapters && (
+
+
+
+ )}
+
{!Platform.isTV && (
track height to extend above) */
+ markerHeight?: number;
+ /** Color of the marker lines */
+ markerColor?: string;
+}
+
+/**
+ * Renders vertical tick marks on the progress bar at chapter positions
+ * Should be overlaid on the slider track
+ */
+export const ChapterMarkers: React.FC = React.memo(
+ ({
+ chapterPositions,
+ style,
+ markerHeight = 15,
+ markerColor = "rgba(255, 255, 255, 0.6)",
+ }) => {
+ if (!chapterPositions.length) {
+ return null;
+ }
+
+ return (
+
+ {chapterPositions.map((position, index) => (
+
+ ))}
+
+ );
+ },
+);
+
+const styles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ marker: {
+ position: "absolute",
+ width: 2,
+ borderRadius: 1,
+ transform: [{ translateX: -1 }], // Center the marker on its position
+ },
+});
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index a336143e..de6326ea 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -33,6 +33,7 @@ import { CONTROLS_CONSTANTS } from "./constants";
import { EpisodeList } from "./EpisodeList";
import { GestureOverlay } from "./GestureOverlay";
import { HeaderControls } from "./HeaderControls";
+import { useChapterNavigation } from "./hooks/useChapterNavigation";
import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider";
@@ -211,6 +212,21 @@ export const Controls: FC = ({
isSeeking,
});
+ // Chapter navigation hook
+ const {
+ hasChapters,
+ hasPreviousChapter,
+ hasNextChapter,
+ goToPreviousChapter,
+ goToNextChapter,
+ chapterPositions,
+ } = useChapterNavigation({
+ chapters: item.Chapters,
+ progress,
+ maxMs,
+ seek,
+ });
+
const toggleControls = useCallback(() => {
if (showControls) {
setShowAudioSlider(false);
@@ -339,10 +355,15 @@ export const Controls: FC = ({
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
- } = getDefaultPlaySettings(item, settings, {
- indexes: previousIndexes,
- source: mediaSource ?? undefined,
- });
+ } = getDefaultPlaySettings(
+ item,
+ settings,
+ {
+ indexes: previousIndexes,
+ source: mediaSource ?? undefined,
+ },
+ { applyLanguagePreferences: true },
+ );
const queryParams = new URLSearchParams({
...(offline && { offline: "true" }),
@@ -521,6 +542,11 @@ export const Controls: FC = ({
togglePlay={togglePlay}
handleSkipBackward={handleSkipBackward}
handleSkipForward={handleSkipForward}
+ hasChapters={hasChapters}
+ hasPreviousChapter={hasPreviousChapter}
+ hasNextChapter={hasNextChapter}
+ goToPreviousChapter={goToPreviousChapter}
+ goToNextChapter={goToNextChapter}
/>
= ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
+ chapterPositions={chapterPositions}
/>
>
diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx
index 01f4ad81..b0e1a886 100644
--- a/components/video-player/controls/Controls.tv.tsx
+++ b/components/video-player/controls/Controls.tv.tsx
@@ -13,7 +13,12 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
-import { StyleSheet, TVFocusGuideView, View } from "react-native";
+import {
+ StyleSheet,
+ TVFocusGuideView,
+ useWindowDimensions,
+ View,
+} from "react-native";
import Animated, {
Easing,
type SharedValue,
@@ -24,22 +29,31 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
-import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
+import {
+ TVControlButton,
+ TVNextEpisodeCountdown,
+ TVSkipSegmentCard,
+} from "@/components/tv";
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
+import { useCreditSkipper } from "@/hooks/useCreditSkipper";
+import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import type { TechnicalInfo } from "@/modules/mpv-player";
+import type { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useVideoContext } from "./contexts/VideoContext";
+import { useChapterNavigation } from "./hooks/useChapterNavigation";
import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
@@ -77,11 +91,102 @@ interface Props {
getTechnicalInfo?: () => Promise;
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
transcodeReasons?: string[];
+ downloadedFiles?: DownloadedItem[];
}
const TV_SEEKBAR_HEIGHT = 14;
const TV_AUTO_HIDE_TIMEOUT = 5000;
+// Trickplay bubble positioning constants
+const TV_TRICKPLAY_SCALE = 2;
+const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
+const TV_TRICKPLAY_BUBBLE_WIDTH =
+ TV_TRICKPLAY_BUBBLE_BASE_WIDTH * TV_TRICKPLAY_SCALE;
+const TV_TRICKPLAY_INTERNAL_OFFSET = 62 * TV_TRICKPLAY_SCALE;
+const TV_TRICKPLAY_CENTERING_OFFSET = 98 * TV_TRICKPLAY_SCALE;
+const TV_TRICKPLAY_RIGHT_PADDING = 150;
+const TV_TRICKPLAY_FADE_DURATION = 200;
+
+interface TVTrickplayBubbleProps {
+ trickPlayUrl: {
+ x: number;
+ y: number;
+ url: string;
+ } | null;
+ trickplayInfo: {
+ aspectRatio?: number;
+ data: {
+ TileWidth?: number;
+ TileHeight?: number;
+ };
+ } | null;
+ time: {
+ hours: number;
+ minutes: number;
+ seconds: number;
+ };
+ progress: SharedValue;
+ max: SharedValue;
+ progressBarWidth: number;
+ visible: boolean;
+}
+
+const TVTrickplayBubblePositioned: FC = ({
+ trickPlayUrl,
+ trickplayInfo,
+ time,
+ progress,
+ max,
+ progressBarWidth,
+ visible,
+}) => {
+ const opacity = useSharedValue(0);
+
+ useEffect(() => {
+ opacity.value = withTiming(visible ? 1 : 0, {
+ duration: TV_TRICKPLAY_FADE_DURATION,
+ easing: Easing.out(Easing.quad),
+ });
+ }, [visible, opacity]);
+
+ const minX = TV_TRICKPLAY_INTERNAL_OFFSET;
+ const maxX =
+ progressBarWidth -
+ TV_TRICKPLAY_BUBBLE_WIDTH +
+ TV_TRICKPLAY_INTERNAL_OFFSET +
+ TV_TRICKPLAY_RIGHT_PADDING;
+
+ const animatedStyle = useAnimatedStyle(() => {
+ const progressPercent = max.value > 0 ? progress.value / max.value : 0;
+
+ const xPosition = Math.max(
+ minX,
+ Math.min(
+ maxX,
+ progressPercent * progressBarWidth -
+ TV_TRICKPLAY_BUBBLE_WIDTH / 2 +
+ TV_TRICKPLAY_CENTERING_OFFSET,
+ ),
+ );
+
+ return {
+ transform: [{ translateX: xPosition }],
+ opacity: opacity.value,
+ };
+ });
+
+ return (
+
+
+
+ );
+};
+
export const Controls: FC = ({
item,
seek,
@@ -110,9 +215,19 @@ export const Controls: FC = ({
getTechnicalInfo,
playMethod,
transcodeReasons,
+ downloadedFiles,
}) => {
+ const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
+ const { width: screenWidth } = useWindowDimensions();
const { t } = useTranslation();
+
+ // Calculate progress bar width (matches the padding used in bottomInner)
+ const progressBarWidth = useMemo(() => {
+ const leftPadding = Math.max(insets.left, 48);
+ const rightPadding = Math.max(insets.right, 48);
+ return screenWidth - leftPadding - rightPadding;
+ }, [screenWidth, insets.left, insets.right]);
const api = useAtomValue(apiAtom);
const { settings } = useSettings();
const router = useRouter();
@@ -271,6 +386,75 @@ export const Controls: FC = ({
isSeeking,
});
+ // Chapter navigation hook
+ const {
+ hasChapters,
+ hasPreviousChapter,
+ hasNextChapter,
+ goToPreviousChapter,
+ goToNextChapter,
+ chapterPositions,
+ } = useChapterNavigation({
+ chapters: item.Chapters,
+ progress,
+ maxMs,
+ seek,
+ });
+
+ // Skip intro/credits hooks
+ // Note: hooks expect seek callback that takes ms, and seek prop already expects ms
+ const offline = useOfflineMode();
+ const { showSkipButton, skipIntro } = useIntroSkipper(
+ item.Id!,
+ currentTime,
+ seek,
+ _play,
+ offline,
+ api,
+ downloadedFiles,
+ );
+
+ const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
+ useCreditSkipper(
+ item.Id!,
+ currentTime,
+ seek,
+ _play,
+ offline,
+ api,
+ downloadedFiles,
+ max.value,
+ );
+
+ // Countdown logic
+ const isCountdownActive = useMemo(() => {
+ if (!nextItem) return false;
+ if (item?.Type !== "Episode") return false;
+ return remainingTime > 0 && remainingTime <= 10000;
+ }, [nextItem, item, remainingTime]);
+
+ // Simple boolean - when skip cards or countdown are visible, they have focus
+ const isSkipOrCountdownVisible = useMemo(() => {
+ const skipIntroVisible = showSkipButton && !isCountdownActive;
+ const skipCreditsVisible =
+ showSkipCreditButton &&
+ (hasContentAfterCredits || !nextItem) &&
+ !isCountdownActive;
+ return skipIntroVisible || skipCreditsVisible || isCountdownActive;
+ }, [
+ showSkipButton,
+ showSkipCreditButton,
+ hasContentAfterCredits,
+ nextItem,
+ isCountdownActive,
+ ]);
+
+ // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels)
+ const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel";
+
+ // For live TV, determine if we're at the live edge (within 5 seconds of max)
+ const LIVE_EDGE_THRESHOLD = 5000; // 5 seconds in ms
+
const getFinishTime = () => {
const now = new Date();
const finishTime = new Date(now.getTime() + remainingTime);
@@ -282,8 +466,9 @@ export const Controls: FC = ({
};
const toggleControls = useCallback(() => {
+ if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't toggle
setShowControls(!showControls);
- }, [showControls, setShowControls]);
+ }, [showControls, setShowControls, isSkipOrCountdownVisible]);
const [showSeekBubble, setShowSeekBubble] = useState(false);
const [seekBubbleTime, setSeekBubbleTime] = useState({
@@ -309,10 +494,6 @@ export const Controls: FC = ({
setSeekBubbleTime({ hours, minutes, seconds });
}, []);
- const handleBack = useCallback(() => {
- // No longer needed since modals are screen-based
- }, []);
-
// Show minimal seek bar (only progress bar, no buttons)
const showMinimalSeek = useCallback(() => {
setShowMinimalSeekBar(true);
@@ -349,16 +530,6 @@ export const Controls: FC = ({
}, 2500);
}, []);
- // Reset minimal seek bar timeout (call on each seek action)
- const _resetMinimalSeekTimeout = useCallback(() => {
- if (minimalSeekBarTimeoutRef.current) {
- clearTimeout(minimalSeekBarTimeoutRef.current);
- }
- minimalSeekBarTimeoutRef.current = setTimeout(() => {
- setShowMinimalSeekBar(false);
- }, 2500);
- }, []);
-
const handleOpenAudioSheet = useCallback(() => {
setLastOpenedModal("audio");
showOptions({
@@ -436,6 +607,13 @@ export const Controls: FC = ({
);
const handleSeekForwardButton = useCallback(() => {
+ // For live TV, check if we're already at the live edge
+ if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
+ // Already at live edge, don't seek further
+ controlsInteractionRef.current();
+ return;
+ }
+
const newPosition = Math.min(max.value, progress.value + 30 * 1000);
progress.value = newPosition;
seek(newPosition);
@@ -452,7 +630,14 @@ export const Controls: FC = ({
}, 2000);
controlsInteractionRef.current();
- }, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
+ }, [
+ progress,
+ max,
+ seek,
+ calculateTrickplayUrl,
+ updateSeekBubbleTime,
+ isLiveTV,
+ ]);
const handleSeekBackwardButton = useCallback(() => {
const newPosition = Math.max(min.value, progress.value - 30 * 1000);
@@ -475,6 +660,13 @@ export const Controls: FC = ({
// Progress bar D-pad seeking (10s increments for finer control)
const handleProgressSeekRight = useCallback(() => {
+ // For live TV, check if we're already at the live edge
+ if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
+ // Already at live edge, don't seek further
+ controlsInteractionRef.current();
+ return;
+ }
+
const newPosition = Math.min(max.value, progress.value + 10 * 1000);
progress.value = newPosition;
seek(newPosition);
@@ -491,7 +683,14 @@ export const Controls: FC = ({
}, 2000);
controlsInteractionRef.current();
- }, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
+ }, [
+ progress,
+ max,
+ seek,
+ calculateTrickplayUrl,
+ updateSeekBubbleTime,
+ isLiveTV,
+ ]);
const handleProgressSeekLeft = useCallback(() => {
const newPosition = Math.max(min.value, progress.value - 10 * 1000);
@@ -514,6 +713,12 @@ export const Controls: FC = ({
// Minimal seek mode handlers (only show progress bar, not full controls)
const handleMinimalSeekRight = useCallback(() => {
+ // For live TV, check if we're already at the live edge
+ if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
+ // Already at live edge, don't seek further
+ return;
+ }
+
const newPosition = Math.min(max.value, progress.value + 10 * 1000);
progress.value = newPosition;
seek(newPosition);
@@ -538,6 +743,7 @@ export const Controls: FC = ({
calculateTrickplayUrl,
updateSeekBubbleTime,
showMinimalSeek,
+ isLiveTV,
]);
const handleMinimalSeekLeft = useCallback(() => {
@@ -587,11 +793,23 @@ export const Controls: FC = ({
}, [startMinimalSeekHideTimeout]);
const startContinuousSeekForward = useCallback(() => {
+ // For live TV, check if we're already at the live edge
+ if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
+ // Already at live edge, don't start continuous seeking
+ return;
+ }
+
seekAccelerationRef.current = 1;
handleSeekForwardButton();
continuousSeekRef.current = setInterval(() => {
+ // For live TV, stop continuous seeking when we hit the live edge
+ if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
+ stopContinuousSeeking();
+ return;
+ }
+
const seekAmount =
CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK *
seekAccelerationRef.current *
@@ -603,7 +821,11 @@ export const Controls: FC = ({
calculateTrickplayUrl(msToTicks(newPosition));
updateSeekBubbleTime(newPosition);
- seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION;
+ seekAccelerationRef.current = Math.min(
+ seekAccelerationRef.current *
+ CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION,
+ CONTROLS_CONSTANTS.LONG_PRESS_MAX_ACCELERATION,
+ );
controlsInteractionRef.current();
}, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL);
@@ -614,6 +836,8 @@ export const Controls: FC = ({
seek,
calculateTrickplayUrl,
updateSeekBubbleTime,
+ isLiveTV,
+ stopContinuousSeeking,
]);
const startContinuousSeekBackward = useCallback(() => {
@@ -633,7 +857,11 @@ export const Controls: FC = ({
calculateTrickplayUrl(msToTicks(newPosition));
updateSeekBubbleTime(newPosition);
- seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION;
+ seekAccelerationRef.current = Math.min(
+ seekAccelerationRef.current *
+ CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION,
+ CONTROLS_CONSTANTS.LONG_PRESS_MAX_ACCELERATION,
+ );
controlsInteractionRef.current();
}, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL);
@@ -668,15 +896,24 @@ export const Controls: FC = ({
// Callback for up/down D-pad - show controls with play button focused
const handleVerticalDpad = useCallback(() => {
+ if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't show controls
setFocusPlayButton(true);
setShowControls(true);
+ }, [setShowControls, isSkipOrCountdownVisible]);
+
+ const hideControls = useCallback(() => {
+ setShowControls(false);
+ setFocusPlayButton(false);
}, [setShowControls]);
+ const handleBack = useCallback(() => {
+ router.back();
+ }, [router]);
+
const { isSliding: isRemoteSliding } = useRemoteControl({
- showControls,
+ showControls: showControls,
toggleControls,
togglePlay,
- onBack: handleBack,
isProgressBarFocused,
onSeekLeft: handleProgressSeekLeft,
onSeekRight: handleProgressSeekRight,
@@ -687,15 +924,13 @@ export const Controls: FC = ({
onLongSeekRightStart: handleDpadLongSeekForward,
onLongSeekStop: stopContinuousSeeking,
onVerticalDpad: handleVerticalDpad,
+ onHideControls: hideControls,
+ onBack: handleBack,
+ videoTitle: item?.Name ?? undefined,
});
- const hideControls = useCallback(() => {
- setShowControls(false);
- setFocusPlayButton(false);
- }, [setShowControls]);
-
const { handleControlsInteraction } = useControlsTimeout({
- showControls,
+ showControls: showControls,
isSliding: isRemoteSliding,
episodeView: false,
onHideControls: hideControls,
@@ -775,12 +1010,6 @@ export const Controls: FC = ({
goToNextItemRef.current = goToNextItem;
- const shouldShowCountdown = useMemo(() => {
- if (!nextItem) return false;
- if (item?.Type !== "Episode") return false;
- return remainingTime > 0 && remainingTime <= 10000;
- }, [nextItem, item, remainingTime]);
-
const handleAutoPlayFinish = useCallback(() => {
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
@@ -805,13 +1034,35 @@ export const Controls: FC = ({
/>
)}
+ {/* Skip intro card */}
+
+
+ {/* Skip credits card - show when there's content after credits, OR no next episode */}
+
+
{nextItem && (
)}
@@ -831,61 +1082,88 @@ export const Controls: FC = ({
},
]}
>
- {showSeekBubble && (
-
-
-
- )}
+
+
+
{/* Same padding as TVFocusableProgressBar for alignment */}
-
- ({
- width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`,
- })),
- ]}
- />
- ({
- width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`,
- })),
- ]}
- />
+
+
+ ({
+ width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`,
+ })),
+ ]}
+ />
+ ({
+ width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`,
+ })),
+ ]}
+ />
+
+ {/* Chapter markers */}
+ {chapterPositions.length > 0 && (
+
+ {chapterPositions.map((position, index) => (
+
+ ))}
+
+ )}
-
+
{formatTimeString(currentTime, "ms")}
-
-
- -{formatTimeString(remainingTime, "ms")}
-
-
- {t("player.ends_at")} {getFinishTime()}
-
-
+ {!isLiveTV && (
+
+
+ -{formatTimeString(remainingTime, "ms")}
+
+
+ {t("player.ends_at")} {getFinishTime()}
+
+
+ )}
= ({
{item?.Type === "Episode" && (
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
)}
- {item?.Name}
+
+
+ {item?.Name}
+
+ {isLiveTV && (
+
+
+ {t("player.live")}
+
+
+ )}
+
{item?.Type === "Movie" && (
- {item?.ProductionYear}
+
+ {item?.ProductionYear}
+
)}
@@ -914,21 +1214,40 @@ export const Controls: FC = ({
+ {hasChapters && (
+
+ )}
+ {hasChapters && (
+
+ )}
@@ -938,8 +1257,9 @@ export const Controls: FC = ({
)}
@@ -947,31 +1267,35 @@ export const Controls: FC = ({
{getTechnicalInfo && (
)}
- {showSeekBubble && (
-
-
-
- )}
+
+
+
{/* Bidirectional focus guides - stacked together per docs */}
{/* Downward: play button → progress bar */}
@@ -995,25 +1319,30 @@ export const Controls: FC = ({
progress={effectiveProgress}
max={max}
cacheProgress={cacheProgress}
+ chapterPositions={chapterPositions}
onFocus={() => setIsProgressBarFocused(true)}
onBlur={() => setIsProgressBarFocused(false)}
refSetter={setProgressBarRef}
- hasTVPreferredFocus={lastOpenedModal === null && !focusPlayButton}
+ hasTVPreferredFocus={false}
/>
-
+
{formatTimeString(currentTime, "ms")}
-
-
- -{formatTimeString(remainingTime, "ms")}
-
-
- {t("player.ends_at")} {getFinishTime()}
-
-
+ {!isLiveTV && (
+
+
+ -{formatTimeString(remainingTime, "ms")}
+
+
+ {t("player.ends_at")} {getFinishTime()}
+
+
+ )}
@@ -1042,13 +1371,26 @@ const styles = StyleSheet.create({
metadataContainer: {
marginBottom: 16,
},
+ titleRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ },
subtitleText: {
color: "rgba(255,255,255,0.6)",
- fontSize: TVTypography.body,
},
titleText: {
color: "#fff",
- fontSize: TVTypography.heading,
+ fontWeight: "bold",
+ },
+ liveBadge: {
+ backgroundColor: "#EF4444",
+ paddingHorizontal: 12,
+ paddingVertical: 4,
+ borderRadius: 6,
+ },
+ liveBadgeText: {
+ color: "#FFF",
fontWeight: "bold",
},
controlButtonsRow: {
@@ -1063,12 +1405,15 @@ const styles = StyleSheet.create({
},
trickplayBubbleContainer: {
position: "absolute",
- bottom: 120,
+ bottom: 190,
left: 0,
right: 0,
- alignItems: "center",
zIndex: 20,
},
+ trickplayBubblePositioned: {
+ position: "absolute",
+ bottom: 0,
+ },
focusGuide: {
height: 1,
width: "100%",
@@ -1108,7 +1453,6 @@ const styles = StyleSheet.create({
},
timeText: {
color: "rgba(255,255,255,0.7)",
- fontSize: TVTypography.body,
},
timeRight: {
flexDirection: "column",
@@ -1116,7 +1460,6 @@ const styles = StyleSheet.create({
},
endsAtText: {
color: "rgba(255,255,255,0.5)",
- fontSize: TVTypography.callout,
marginTop: 2,
},
// Minimal seek bar styles
@@ -1144,4 +1487,24 @@ const styles = StyleSheet.create({
// Brighter track like focused state
backgroundColor: "rgba(255,255,255,0.35)",
},
+ minimalProgressTrackWrapper: {
+ position: "relative",
+ height: TV_SEEKBAR_HEIGHT,
+ },
+ minimalChapterMarkersContainer: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ minimalChapterMarker: {
+ position: "absolute",
+ width: 2,
+ height: TV_SEEKBAR_HEIGHT + 5,
+ bottom: 0,
+ backgroundColor: "rgba(255, 255, 255, 0.6)",
+ borderRadius: 1,
+ transform: [{ translateX: -1 }],
+ },
});
diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx
index 5d87e697..ad6dded5 100644
--- a/components/video-player/controls/TechnicalInfoOverlay.tsx
+++ b/components/video-player/controls/TechnicalInfoOverlay.tsx
@@ -15,7 +15,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { TVTypography } from "@/constants/TVTypography";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants";
@@ -183,6 +183,7 @@ export const TechnicalInfoOverlay: FC = memo(
currentSubtitleIndex,
currentAudioIndex,
}) => {
+ const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const [info, setInfo] = useState(null);
@@ -277,8 +278,15 @@ export const TechnicalInfoOverlay: FC = memo(
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
};
- const textStyle = Platform.isTV ? styles.infoTextTV : styles.infoText;
- const reasonStyle = Platform.isTV ? styles.reasonTextTV : styles.reasonText;
+ const textStyle = Platform.isTV
+ ? [
+ styles.infoTextTV,
+ { fontSize: typography.body, lineHeight: typography.body * 1.5 },
+ ]
+ : styles.infoText;
+ const reasonStyle = Platform.isTV
+ ? [styles.reasonTextTV, { fontSize: typography.callout }]
+ : styles.reasonText;
const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox;
return (
@@ -383,9 +391,7 @@ const styles = StyleSheet.create({
},
infoTextTV: {
color: "white",
- fontSize: TVTypography.body,
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
- lineHeight: TVTypography.body * 1.5,
},
warningText: {
color: "#ff9800",
@@ -396,6 +402,5 @@ const styles = StyleSheet.create({
},
reasonTextTV: {
color: "#fbbf24",
- fontSize: TVTypography.callout,
},
});
diff --git a/components/video-player/controls/TrickplayBubble.tsx b/components/video-player/controls/TrickplayBubble.tsx
index 49645ed2..416bb92c 100644
--- a/components/video-player/controls/TrickplayBubble.tsx
+++ b/components/video-player/controls/TrickplayBubble.tsx
@@ -4,6 +4,10 @@ import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { CONTROLS_CONSTANTS } from "./constants";
+const BASE_IMAGE_SCALE = 1.4;
+const BUBBLE_LEFT_OFFSET = 62;
+const BUBBLE_WIDTH_MULTIPLIER = 1.5;
+
interface TrickplayBubbleProps {
trickPlayUrl: {
x: number;
@@ -22,12 +26,21 @@ interface TrickplayBubbleProps {
minutes: number;
seconds: number;
};
+ /** Scale factor for the image (default 1). Does not affect timestamp text. */
+ imageScale?: number;
+}
+
+function formatTime(hours: number, minutes: number, seconds: number): string {
+ const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
+ const prefix = hours > 0 ? `${hours}:` : "";
+ return `${prefix}${pad(minutes)}:${pad(seconds)}`;
}
export const TrickplayBubble: FC = ({
trickPlayUrl,
trickplayInfo,
time,
+ imageScale = 1,
}) => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
@@ -36,16 +49,17 @@ export const TrickplayBubble: FC = ({
const { x, y, url } = trickPlayUrl;
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
+ const finalScale = BASE_IMAGE_SCALE * imageScale;
return (
= ({
width: tileWidth,
height: tileHeight,
alignSelf: "center",
- transform: [{ scale: 1.4 }],
+ transform: [{ scale: finalScale }],
borderRadius: 5,
}}
className='bg-neutral-800 overflow-hidden'
>
= ({
contentFit='cover'
/>
-
- {`${time.hours > 0 ? `${time.hours}:` : ""}${
- time.minutes < 10 ? `0${time.minutes}` : time.minutes
- }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
+
+ {formatTime(time.hours, time.minutes, time.seconds)}
);
diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts
index 06f661db..cec24162 100644
--- a/components/video-player/controls/constants.ts
+++ b/components/video-player/controls/constants.ts
@@ -7,6 +7,7 @@ export const CONTROLS_CONSTANTS = {
PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks
LONG_PRESS_INITIAL_SEEK: 30,
LONG_PRESS_ACCELERATION: 1.2,
+ LONG_PRESS_MAX_ACCELERATION: 4,
LONG_PRESS_INTERVAL: 300,
SLIDER_DEBOUNCE_MS: 3,
} as const;
diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx
index ec9ca995..7c575084 100644
--- a/components/video-player/controls/contexts/VideoContext.tsx
+++ b/components/video-player/controls/contexts/VideoContext.tsx
@@ -47,6 +47,7 @@
*/
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
+import { File } from "expo-file-system";
import { useLocalSearchParams } from "expo-router";
import type React from "react";
import {
@@ -57,13 +58,19 @@ import {
useMemo,
useState,
} from "react";
+import { Platform } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import type { MpvAudioTrack } from "@/modules";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types";
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
+// Starting index for local (client-downloaded) subtitles
+// Uses negative indices to avoid collision with Jellyfin indices
+const LOCAL_SUBTITLE_INDEX_START = -100;
+
interface VideoContextProps {
subtitleTracks: Track[] | null;
audioTracks: Track[] | null;
@@ -339,12 +346,40 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
};
});
+ // TV only: Merge locally downloaded subtitles (from OpenSubtitles)
+ if (Platform.isTV && itemId) {
+ const localSubs = getSubtitlesForItem(itemId);
+ 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;
+ subs.push({
+ name: localSub.name,
+ index: localIndex,
+ mpvIndex: -1, // Will be loaded dynamically via addSubtitleFile
+ isLocal: true,
+ localPath: localSub.filePath,
+ setTrack: () => {
+ // Add the subtitle file to MPV and select it
+ playerControls.addSubtitleFile(localSub.filePath, true);
+ router.setParams({ subtitleIndex: String(localIndex) });
+ },
+ });
+ localIdx++;
+ }
+ }
+
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
setAudioTracks(audio);
};
fetchTracks();
- }, [tracksReady, mediaSource, offline, downloadedItem]);
+ }, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
return (
diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx
index 5b631ec4..7b6713b3 100644
--- a/components/video-player/controls/dropdown/DropdownView.tsx
+++ b/components/video-player/controls/dropdown/DropdownView.tsx
@@ -15,16 +15,18 @@ import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext";
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
-// Subtitle size presets (stored as scale * 100, so 1.0 = 100)
-const SUBTITLE_SIZE_PRESETS = [
- { label: "0.5", value: 50 },
- { label: "0.6", value: 60 },
- { label: "0.7", value: 70 },
- { label: "0.8", value: 80 },
- { label: "0.9", value: 90 },
- { label: "1.0", value: 100 },
- { label: "1.1", value: 110 },
- { label: "1.2", value: 120 },
+// Subtitle scale presets (direct multiplier values)
+const SUBTITLE_SCALE_PRESETS = [
+ { label: "0.1x", value: 0.1 },
+ { label: "0.25x", value: 0.25 },
+ { label: "0.5x", value: 0.5 },
+ { label: "0.75x", value: 0.75 },
+ { label: "1.0x", value: 1.0 },
+ { label: "1.25x", value: 1.25 },
+ { label: "1.5x", value: 1.5 },
+ { label: "2.0x", value: 2.0 },
+ { label: "2.5x", value: 2.5 },
+ { label: "3.0x", value: 3.0 },
] as const;
interface DropdownViewProps {
@@ -124,15 +126,15 @@ const DropdownView = ({
})),
});
- // Subtitle Size Section
+ // Subtitle Scale Section
groups.push({
- title: "Subtitle Size",
- options: SUBTITLE_SIZE_PRESETS.map((preset) => ({
+ title: "Subtitle Scale",
+ options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
type: "radio" as const,
label: preset.label,
value: preset.value.toString(),
- selected: settings.subtitleSize === preset.value,
- onPress: () => updateSettings({ subtitleSize: preset.value }),
+ selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value,
+ onPress: () => updateSettings({ mpvSubtitleScale: preset.value }),
})),
});
}
@@ -190,7 +192,7 @@ const DropdownView = ({
audioTracksKey,
subtitleIndex,
audioIndex,
- settings.subtitleSize,
+ settings.mpvSubtitleScale,
updateSettings,
playbackSpeed,
setPlaybackSpeed,
diff --git a/components/video-player/controls/hooks/index.ts b/components/video-player/controls/hooks/index.ts
index 08b234ac..cfb31759 100644
--- a/components/video-player/controls/hooks/index.ts
+++ b/components/video-player/controls/hooks/index.ts
@@ -1,3 +1,4 @@
+export { useChapterNavigation } from "./useChapterNavigation";
export { useRemoteControl } from "./useRemoteControl";
export { useVideoNavigation } from "./useVideoNavigation";
export { useVideoSlider } from "./useVideoSlider";
diff --git a/components/video-player/controls/hooks/useChapterNavigation.ts b/components/video-player/controls/hooks/useChapterNavigation.ts
new file mode 100644
index 00000000..00d3330c
--- /dev/null
+++ b/components/video-player/controls/hooks/useChapterNavigation.ts
@@ -0,0 +1,150 @@
+import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client";
+import { useCallback, useMemo } from "react";
+import type { SharedValue } from "react-native-reanimated";
+import { ticksToMs } from "@/utils/time";
+
+export interface UseChapterNavigationProps {
+ /** Chapters array from the item */
+ chapters: ChapterInfo[] | null | undefined;
+ /** Current progress in milliseconds (SharedValue) */
+ progress: SharedValue;
+ /** Total duration in milliseconds */
+ maxMs: number;
+ /** Seek function that accepts milliseconds */
+ seek: (ms: number) => void;
+}
+
+export interface UseChapterNavigationReturn {
+ /** Array of chapters */
+ chapters: ChapterInfo[];
+ /** Index of the current chapter (-1 if no chapters) */
+ currentChapterIndex: number;
+ /** Current chapter info or null */
+ currentChapter: ChapterInfo | null;
+ /** Whether there's a next chapter available */
+ hasNextChapter: boolean;
+ /** Whether there's a previous chapter available */
+ hasPreviousChapter: boolean;
+ /** Navigate to the next chapter */
+ goToNextChapter: () => void;
+ /** Navigate to the previous chapter (or restart current if >3s in) */
+ goToPreviousChapter: () => void;
+ /** Array of chapter positions as percentages (0-100) for tick marks */
+ chapterPositions: number[];
+ /** Whether chapters are available */
+ hasChapters: boolean;
+}
+
+// Threshold in ms - if more than 3 seconds into chapter, restart instead of going to previous
+const RESTART_THRESHOLD_MS = 3000;
+
+/**
+ * Hook for chapter navigation in video player
+ * Provides current chapter info and navigation functions
+ */
+export function useChapterNavigation({
+ chapters: rawChapters,
+ progress,
+ maxMs,
+ seek,
+}: UseChapterNavigationProps): UseChapterNavigationReturn {
+ // Ensure chapters is always an array
+ const chapters = useMemo(() => rawChapters ?? [], [rawChapters]);
+
+ // Calculate chapter positions as percentages for tick marks
+ const chapterPositions = useMemo(() => {
+ if (!chapters.length || maxMs <= 0) return [];
+
+ return chapters
+ .map((chapter) => {
+ const positionMs = ticksToMs(chapter.StartPositionTicks);
+ return (positionMs / maxMs) * 100;
+ })
+ .filter((pos) => pos > 0 && pos < 100); // Skip first (0%) and any at the end
+ }, [chapters, maxMs]);
+
+ // Find current chapter index based on progress
+ // The current chapter is the one with the largest StartPositionTicks that is <= current progress
+ const getCurrentChapterIndex = useCallback((): number => {
+ if (!chapters.length) return -1;
+
+ const currentMs = progress.value;
+ let currentIndex = -1;
+
+ for (let i = 0; i < chapters.length; i++) {
+ const chapterMs = ticksToMs(chapters[i].StartPositionTicks);
+ if (chapterMs <= currentMs) {
+ currentIndex = i;
+ } else {
+ break;
+ }
+ }
+
+ return currentIndex;
+ }, [chapters, progress]);
+
+ // Current chapter index (computed once for rendering)
+ const currentChapterIndex = getCurrentChapterIndex();
+
+ // Current chapter info
+ const currentChapter = useMemo(() => {
+ if (currentChapterIndex < 0 || currentChapterIndex >= chapters.length) {
+ return null;
+ }
+ return chapters[currentChapterIndex];
+ }, [chapters, currentChapterIndex]);
+
+ // Navigation availability
+ const hasNextChapter =
+ chapters.length > 0 && currentChapterIndex < chapters.length - 1;
+ const hasPreviousChapter = chapters.length > 0 && currentChapterIndex >= 0;
+
+ // Navigate to next chapter
+ const goToNextChapter = useCallback(() => {
+ const idx = getCurrentChapterIndex();
+ if (idx < chapters.length - 1) {
+ const nextChapter = chapters[idx + 1];
+ const nextMs = ticksToMs(nextChapter.StartPositionTicks);
+ progress.value = nextMs;
+ seek(nextMs);
+ }
+ }, [chapters, getCurrentChapterIndex, progress, seek]);
+
+ // Navigate to previous chapter (or restart current if >3s in)
+ const goToPreviousChapter = useCallback(() => {
+ const idx = getCurrentChapterIndex();
+ if (idx < 0) return;
+
+ const currentChapterMs = ticksToMs(chapters[idx].StartPositionTicks);
+ const currentMs = progress.value;
+ const timeIntoChapter = currentMs - currentChapterMs;
+
+ // If more than 3 seconds into the current chapter, restart it
+ // Otherwise, go to the previous chapter
+ if (timeIntoChapter > RESTART_THRESHOLD_MS && idx >= 0) {
+ progress.value = currentChapterMs;
+ seek(currentChapterMs);
+ } else if (idx > 0) {
+ const prevChapter = chapters[idx - 1];
+ const prevMs = ticksToMs(prevChapter.StartPositionTicks);
+ progress.value = prevMs;
+ seek(prevMs);
+ } else {
+ // At the first chapter, just restart it
+ progress.value = currentChapterMs;
+ seek(currentChapterMs);
+ }
+ }, [chapters, getCurrentChapterIndex, progress, seek]);
+
+ return {
+ chapters,
+ currentChapterIndex,
+ currentChapter,
+ hasNextChapter,
+ hasPreviousChapter,
+ goToNextChapter,
+ goToPreviousChapter,
+ chapterPositions,
+ hasChapters: chapters.length > 0,
+ };
+}
diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts
index c813d141..513c8dd7 100644
--- a/components/video-player/controls/hooks/useRemoteControl.ts
+++ b/components/video-player/controls/hooks/useRemoteControl.ts
@@ -1,5 +1,5 @@
-import { useState } from "react";
-import { Platform } from "react-native";
+import { useEffect, useRef, useState } from "react";
+import { Alert, BackHandler, Platform } from "react-native";
import { type SharedValue, useSharedValue } from "react-native-reanimated";
// TV event handler with fallback for non-TV platforms
@@ -23,6 +23,10 @@ interface UseRemoteControlProps {
disableSeeking?: boolean;
/** Callback for back/menu button press (tvOS: menu, Android TV: back) */
onBack?: () => void;
+ /** Callback to hide controls (called on back press when controls are visible) */
+ onHideControls?: () => void;
+ /** Title of the video being played (shown in exit confirmation) */
+ videoTitle?: string;
/** Whether the progress bar currently has focus */
isProgressBarFocused?: boolean;
/** Callback for seeking left when progress bar is focused */
@@ -69,6 +73,8 @@ export function useRemoteControl({
toggleControls,
togglePlay,
onBack,
+ onHideControls,
+ videoTitle,
isProgressBarFocused,
onSeekLeft,
onSeekRight,
@@ -87,23 +93,80 @@ export function useRemoteControl({
const [isSliding] = useState(false);
const [time] = useState({ hours: 0, minutes: 0, seconds: 0 });
+ // Use refs to avoid stale closures in BackHandler
+ const showControlsRef = useRef(showControls);
+ const onHideControlsRef = useRef(onHideControls);
+ const onBackRef = useRef(onBack);
+ const videoTitleRef = useRef(videoTitle);
+
+ useEffect(() => {
+ showControlsRef.current = showControls;
+ onHideControlsRef.current = onHideControls;
+ onBackRef.current = onBack;
+ videoTitleRef.current = videoTitle;
+ }, [showControls, onHideControls, onBack, videoTitle]);
+
+ // Handle hardware back button (works on both Android TV and tvOS)
+ useEffect(() => {
+ if (!Platform.isTV) return;
+
+ const handleBackPress = () => {
+ if (showControlsRef.current && onHideControlsRef.current) {
+ // Controls are visible - just hide them
+ onHideControlsRef.current();
+ return true; // Prevent default back navigation
+ }
+ if (onBackRef.current) {
+ // Controls are hidden - show confirmation before exiting
+ Alert.alert(
+ "Stop Playback",
+ videoTitleRef.current
+ ? `Stop playing "${videoTitleRef.current}"?`
+ : "Are you sure you want to stop playback?",
+ [
+ { text: "Cancel", style: "cancel" },
+ { text: "Stop", style: "destructive", onPress: onBackRef.current },
+ ],
+ );
+ return true; // Prevent default back navigation
+ }
+ return false; // Let default back navigation happen
+ };
+
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ handleBackPress,
+ );
+
+ return () => subscription.remove();
+ }, []);
+
// TV remote control handling (no-op on non-TV platforms)
useTVEventHandler((evt) => {
if (!evt) return;
- // Handle back/menu button press (tvOS: menu, Android TV: back)
- if (evt.eventType === "menu" || evt.eventType === "back") {
- if (onBack) {
- onBack();
+ // Back/menu is handled by BackHandler above, but keep this for tvOS menu button
+ if (evt.eventType === "menu") {
+ if (showControls && onHideControls) {
+ onHideControls();
+ } else if (onBack) {
+ Alert.alert(
+ "Stop Playback",
+ videoTitle
+ ? `Stop playing "${videoTitle}"?`
+ : "Are you sure you want to stop playback?",
+ [
+ { text: "Cancel", style: "cancel" },
+ { text: "Stop", style: "destructive", onPress: onBack },
+ ],
+ );
}
return;
}
// Handle play/pause button press on TV remote
if (evt.eventType === "playPause") {
- if (togglePlay) {
- togglePlay();
- }
+ togglePlay?.();
onInteraction?.();
return;
}
@@ -134,6 +197,11 @@ export function useRemoteControl({
// Handle D-pad when controls are hidden
if (!showControls) {
+ // Ignore select/enter events - let the native Pressable handle them
+ // This prevents controls from showing when pressing buttons like skip intro
+ if (evt.eventType === "select" || evt.eventType === "enter") {
+ return;
+ }
// Minimal seek mode for left/right
if (evt.eventType === "left" && onMinimalSeekLeft) {
onMinimalSeekLeft();
@@ -151,8 +219,8 @@ export function useRemoteControl({
onVerticalDpad();
return;
}
- // For other D-pad presses, show full controls
- toggleControls();
+ // Ignore all other events (focus/blur, swipes, etc.)
+ // User can press up/down to show controls
return;
}
diff --git a/components/video-player/controls/types.ts b/components/video-player/controls/types.ts
index 5ec03edd..30f277aa 100644
--- a/components/video-player/controls/types.ts
+++ b/components/video-player/controls/types.ts
@@ -22,6 +22,10 @@ type Track = {
index: number;
mpvIndex?: number;
setTrack: () => void;
+ /** True for client-side downloaded subtitles (e.g., from OpenSubtitles) */
+ isLocal?: boolean;
+ /** File path for local subtitles */
+ localPath?: string;
};
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
diff --git a/constants/TVPosterSizes.ts b/constants/TVPosterSizes.ts
new file mode 100644
index 00000000..132b75a6
--- /dev/null
+++ b/constants/TVPosterSizes.ts
@@ -0,0 +1,12 @@
+/**
+ * @deprecated Import from "@/constants/TVSizes" instead.
+ * This file is kept for backwards compatibility.
+ */
+
+export {
+ type ScaledTVPosterSizes,
+ TVPosterSizes,
+ useScaledTVPosterSizes,
+} from "./TVSizes";
+
+export type TVPosterSizeKey = keyof typeof import("./TVSizes").TVPosterSizes;
diff --git a/constants/TVSizes.ts b/constants/TVSizes.ts
new file mode 100644
index 00000000..20c38daa
--- /dev/null
+++ b/constants/TVSizes.ts
@@ -0,0 +1,175 @@
+import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
+
+/**
+ * TV Layout Sizes
+ *
+ * Unified constants for TV interface layout including posters, gaps, and padding.
+ * All values scale based on the user's tvTypographyScale setting.
+ */
+
+// =============================================================================
+// BASE VALUES (at Default scale)
+// =============================================================================
+
+/**
+ * Base poster widths in pixels.
+ * Heights are calculated from aspect ratios.
+ */
+export const TVPosterSizes = {
+ /** Portrait posters (movies, series) - 10:15 aspect ratio */
+ poster: 210,
+
+ /** Landscape posters (continue watching, thumbs, hero) - 16:9 aspect ratio */
+ landscape: 340,
+
+ /** Episode cards - 16:9 aspect ratio */
+ episode: 320,
+} as const;
+
+/**
+ * Base gap/spacing values in pixels.
+ */
+export const TVGaps = {
+ /** Gap between items in horizontal lists */
+ item: 24,
+
+ /** Gap between sections vertically */
+ section: 32,
+
+ /** Small gap for tight layouts */
+ small: 12,
+
+ /** Large gap for spacious layouts */
+ large: 48,
+} as const;
+
+/**
+ * Base padding values in pixels.
+ */
+export const TVPadding = {
+ /** Horizontal padding from screen edges */
+ horizontal: 60,
+
+ /** Padding to accommodate scale animations (1.05x) */
+ scale: 20,
+
+ /** Vertical padding for content areas */
+ vertical: 24,
+
+ /** Hero section height as percentage of screen height (0.0 - 1.0) */
+ heroHeight: 0.6,
+} as const;
+
+/**
+ * Animation and interaction values.
+ */
+export const TVAnimation = {
+ /** Scale factor for focused items */
+ focusScale: 1.05,
+} as const;
+
+// =============================================================================
+// SCALING
+// =============================================================================
+
+/**
+ * Scale multipliers for each typography scale level.
+ * Applied to poster sizes and gaps.
+ */
+const sizeScaleMultipliers: Record = {
+ [TVTypographyScale.Small]: 0.9,
+ [TVTypographyScale.Default]: 1.0,
+ [TVTypographyScale.Large]: 1.1,
+ [TVTypographyScale.ExtraLarge]: 1.2,
+};
+
+// =============================================================================
+// HOOKS
+// =============================================================================
+
+export type ScaledTVPosterSizes = {
+ poster: number;
+ landscape: number;
+ episode: number;
+};
+
+export type ScaledTVGaps = {
+ item: number;
+ section: number;
+ small: number;
+ large: number;
+};
+
+export type ScaledTVPadding = {
+ horizontal: number;
+ scale: number;
+ vertical: number;
+ heroHeight: number;
+};
+
+export type ScaledTVSizes = {
+ posters: ScaledTVPosterSizes;
+ gaps: ScaledTVGaps;
+ padding: ScaledTVPadding;
+ animation: typeof TVAnimation;
+};
+
+/**
+ * Hook that returns all scaled TV sizes based on user settings.
+ *
+ * @example
+ * const sizes = useScaledTVSizes();
+ *
+ */
+export const useScaledTVSizes = (): ScaledTVSizes => {
+ const { settings } = useSettings();
+ const scale =
+ sizeScaleMultipliers[settings.tvTypographyScale] ??
+ sizeScaleMultipliers[TVTypographyScale.Default];
+
+ return {
+ posters: {
+ poster: Math.round(TVPosterSizes.poster * scale),
+ landscape: Math.round(TVPosterSizes.landscape * scale),
+ episode: Math.round(TVPosterSizes.episode * scale),
+ },
+ gaps: {
+ item: Math.round(TVGaps.item * scale),
+ section: Math.round(TVGaps.section * scale),
+ small: Math.round(TVGaps.small * scale),
+ large: Math.round(TVGaps.large * scale),
+ },
+ padding: {
+ horizontal: Math.round(TVPadding.horizontal * scale),
+ scale: Math.round(TVPadding.scale * scale),
+ vertical: Math.round(TVPadding.vertical * scale),
+ heroHeight: TVPadding.heroHeight * scale,
+ },
+ animation: TVAnimation,
+ };
+};
+
+/**
+ * Hook that returns only scaled poster sizes.
+ * Use this for backwards compatibility or when you only need poster sizes.
+ */
+export const useScaledTVPosterSizes = (): ScaledTVPosterSizes => {
+ const sizes = useScaledTVSizes();
+ return sizes.posters;
+};
+
+/**
+ * Hook that returns only scaled gap sizes.
+ */
+export const useScaledTVGaps = (): ScaledTVGaps => {
+ const sizes = useScaledTVSizes();
+ return sizes.gaps;
+};
+
+/**
+ * Hook that returns only scaled padding sizes.
+ */
+export const useScaledTVPadding = (): ScaledTVPadding => {
+ const sizes = useScaledTVSizes();
+ return sizes.padding;
+};
diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts
index ec3fb43b..a2ac3b80 100644
--- a/constants/TVTypography.ts
+++ b/constants/TVTypography.ts
@@ -1,3 +1,5 @@
+import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
+
/**
* TV Typography Scale
*
@@ -23,3 +25,29 @@ export const TVTypography = {
} as const;
export type TVTypographyKey = keyof typeof TVTypography;
+
+const scaleMultipliers: Record = {
+ [TVTypographyScale.Small]: 0.85,
+ [TVTypographyScale.Default]: 1.0,
+ [TVTypographyScale.Large]: 1.2,
+ [TVTypographyScale.ExtraLarge]: 1.4,
+};
+
+/**
+ * Hook that returns scaled TV typography values based on user settings.
+ * Use this instead of the static TVTypography constant for dynamic scaling.
+ */
+export const useScaledTVTypography = () => {
+ const { settings } = useSettings();
+ const scale =
+ scaleMultipliers[settings.tvTypographyScale] ??
+ scaleMultipliers[TVTypographyScale.Default];
+
+ return {
+ display: Math.round(TVTypography.display * scale),
+ title: Math.round(TVTypography.title * scale),
+ heading: Math.round(TVTypography.heading * scale),
+ body: Math.round(TVTypography.body * scale),
+ callout: Math.round(TVTypography.callout * scale),
+ };
+};
diff --git a/docs/tv-modal-guide.md b/docs/tv-modal-guide.md
new file mode 100644
index 00000000..a1b57e9b
--- /dev/null
+++ b/docs/tv-modal-guide.md
@@ -0,0 +1,416 @@
+# TV Modal Guide
+
+This document explains how to implement modals, bottom sheets, and overlays on Apple TV and Android TV in React Native.
+
+## The Problem
+
+On TV platforms, modals have unique challenges:
+- The hardware back button must work correctly to dismiss modals
+- Focus management must be handled explicitly
+- React Native's `Modal` component breaks the TV focus chain
+- Overlay/absolute-positioned modals don't handle back button correctly
+
+## Navigation-Based Modal Pattern (Recommended)
+
+For modals that need proper back button support, use the **navigation-based modal pattern**. This leverages Expo Router's stack navigation with transparent modal presentation.
+
+### Architecture
+
+```
+┌─────────────────────────────────────┐
+│ 1. Jotai Atom (state) │
+│ Stores modal data/params │
+├─────────────────────────────────────┤
+│ 2. Hook (trigger) │
+│ Sets atom + calls router.push() │
+├─────────────────────────────────────┤
+│ 3. Page File (UI) │
+│ Reads atom, renders modal │
+│ Clears atom on unmount │
+├─────────────────────────────────────┤
+│ 4. Stack.Screen (config) │
+│ presentation: transparentModal │
+│ animation: fade │
+└─────────────────────────────────────┘
+```
+
+### Step 1: Create the Atom
+
+Create a Jotai atom to store the modal state/data:
+
+```typescript
+// utils/atoms/tvExampleModal.ts
+import { atom } from "jotai";
+
+export interface TVExampleModalData {
+ itemId: string;
+ title: string;
+ // ... other data the modal needs
+}
+
+export const tvExampleModalAtom = atom(null);
+```
+
+### Step 2: Create the Hook
+
+Create a hook that sets the atom and navigates to the modal:
+
+```typescript
+// hooks/useTVExampleModal.ts
+import { useSetAtom } from "jotai";
+import { router } from "expo-router";
+import { tvExampleModalAtom, TVExampleModalData } from "@/utils/atoms/tvExampleModal";
+
+export const useTVExampleModal = () => {
+ const setModalData = useSetAtom(tvExampleModalAtom);
+
+ const openModal = (data: TVExampleModalData) => {
+ setModalData(data);
+ router.push("/tv-example-modal");
+ };
+
+ return { openModal };
+};
+```
+
+### Step 3: Create the Modal Page
+
+Create a page file that reads the atom and renders the modal UI:
+
+```typescript
+// app/(auth)/tv-example-modal.tsx
+import { useEffect } from "react";
+import { View, Pressable, Text } from "react-native";
+import { useAtom } from "jotai";
+import { router } from "expo-router";
+import { BlurView } from "expo-blur";
+import { tvExampleModalAtom } from "@/utils/atoms/tvExampleModal";
+
+export default function TVExampleModal() {
+ const [modalData, setModalData] = useAtom(tvExampleModalAtom);
+
+ // Clear atom on unmount
+ useEffect(() => {
+ return () => {
+ setModalData(null);
+ };
+ }, [setModalData]);
+
+ // Handle case where modal is opened without data
+ if (!modalData) {
+ router.back();
+ return null;
+ }
+
+ return (
+
+ {/* Background overlay */}
+ router.back()}
+ />
+
+ {/* Modal content */}
+
+
+ {modalData.title}
+
+ {/* Modal content here */}
+
+ router.back()}
+ hasTVPreferredFocus
+ style={({ focused }) => ({
+ marginTop: 24,
+ padding: 16,
+ borderRadius: 8,
+ backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
+ })}
+ >
+ {({ focused }) => (
+
+ Close
+
+ )}
+
+
+
+ );
+}
+```
+
+### Step 4: Add Stack.Screen Configuration
+
+Add the modal route to `app/_layout.tsx`:
+
+```typescript
+// In app/_layout.tsx, inside your Stack navigator
+
+```
+
+### Usage
+
+```typescript
+// In any component
+import { useTVExampleModal } from "@/hooks/useTVExampleModal";
+
+const MyComponent = () => {
+ const { openModal } = useTVExampleModal();
+
+ return (
+ openModal({ itemId: "123", title: "Example" })}
+ >
+ Open Modal
+
+ );
+};
+```
+
+### Reference Implementation
+
+See `useTVRequestModal` + `app/(auth)/tv-request-modal.tsx` for a complete working example.
+
+---
+
+## Bottom Sheet Pattern (Inline Overlays)
+
+For simpler overlays that don't need back button navigation (like option selectors), use an **inline absolute-positioned overlay**. This pattern is ideal for:
+- Dropdown selectors
+- Quick action menus
+- Option pickers
+
+### Key Principles
+
+1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain
+2. **Horizontal ScrollView for options** - Natural for TV remotes (left/right D-pad)
+3. **Disable background focus** - Prevent focus flickering between overlay and background
+
+### Implementation
+
+```typescript
+import { useState } from "react";
+import { View, ScrollView, Pressable, Text } from "react-native";
+import { BlurView } from "expo-blur";
+
+const TVOptionSelector: React.FC<{
+ options: { label: string; value: string }[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+ isOpen: boolean;
+ onClose: () => void;
+}> = ({ options, selectedValue, onSelect, isOpen, onClose }) => {
+ if (!isOpen) return null;
+
+ const selectedIndex = options.findIndex(o => o.value === selectedValue);
+
+ return (
+
+
+
+ {options.map((option, index) => (
+ {
+ onSelect(option.value);
+ onClose();
+ }}
+ />
+ ))}
+
+
+
+ );
+};
+```
+
+### Option Card Component
+
+```typescript
+import { useState, useRef, useEffect } from "react";
+import { Pressable, Text, Animated } from "react-native";
+
+const TVOptionCard: React.FC<{
+ label: string;
+ isSelected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+}> = ({ label, isSelected, hasTVPreferredFocus, onPress }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (toValue: number) => {
+ Animated.spring(scale, {
+ toValue,
+ useNativeDriver: true,
+ tension: 50,
+ friction: 7,
+ }).start();
+ };
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+
+ {label}
+
+
+
+ );
+};
+```
+
+### Reference Implementation
+
+See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`.
+
+---
+
+## Focus Management for Overlays
+
+**CRITICAL**: When displaying overlays 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.
+
+### 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(null);
+const isModalOpen = openModal !== null;
+
+// 2. Each focusable component accepts disabled prop
+const TVFocusableButton: React.FC<{
+ onPress: () => void;
+ disabled?: boolean;
+}> = ({ onPress, disabled }) => (
+
+ {/* content */}
+
+);
+
+// 3. Pass disabled to all background components when modal is open
+
+```
+
+### Reference Implementation
+
+See `settings.tv.tsx` for a complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
+
+---
+
+## Focus Trapping
+
+For modals that should trap focus (prevent navigation outside the modal), use `TVFocusGuideView` with trap props:
+
+```typescript
+import { TVFocusGuideView } from "react-native";
+
+
+ {/* Modal content - focus cannot escape */}
+
+```
+
+**Warning**: Don't use `autoFocus` on focus guide wrappers when you also have bidirectional focus guides - it can interfere with navigation.
+
+---
+
+## Common Mistakes
+
+| Mistake | Result | Fix |
+|---------|--------|-----|
+| Using React Native `Modal` | Focus chain breaks | Use navigation-based or absolute positioning |
+| Overlay without disabling background focus | Focus flickering loop | Add `disabled` prop to all background focusables |
+| No `hasTVPreferredFocus` in modal | Focus stuck on background | Set preferred focus on first modal element |
+| Missing `presentation: "transparentModal"` | Modal not transparent | Add to Stack.Screen options |
+| Not clearing atom on unmount | Stale data on reopen | Clear in useEffect cleanup |
+
+---
+
+## When to Use Which Pattern
+
+| Scenario | Pattern |
+|----------|---------|
+| Full-screen modal with back button | Navigation-based modal |
+| Confirmation dialogs | Navigation-based modal |
+| Option selectors / dropdowns | Bottom sheet (inline) |
+| Quick action menus | Bottom sheet (inline) |
+| Complex forms | Navigation-based modal |
diff --git a/eas.json b/eas.json
index 5ecd93c3..0a17c22a 100644
--- a/eas.json
+++ b/eas.json
@@ -81,6 +81,9 @@
"channel": "0.52.0",
"env": {
"EXPO_TV": "1"
+ },
+ "ios": {
+ "credentialsSource": "local"
}
}
},
diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts
index 255a114d..ad292639 100644
--- a/hooks/useDefaultPlaySettings.ts
+++ b/hooks/useDefaultPlaySettings.ts
@@ -1,19 +1,27 @@
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import type { Settings } from "@/utils/atoms/settings";
-import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
+import {
+ getDefaultPlaySettings,
+ type PlaySettingsOptions,
+} from "@/utils/jellyfin/getDefaultPlaySettings";
/**
* React hook wrapper for getDefaultPlaySettings.
* Used in UI components for initial playback (no previous track state).
+ *
+ * @param item - The media item to play
+ * @param settings - User settings (language preferences, bitrate, etc.)
+ * @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV)
*/
const useDefaultPlaySettings = (
item: BaseItemDto | null | undefined,
settings: Settings | null,
+ options?: PlaySettingsOptions,
) =>
useMemo(() => {
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
- getDefaultPlaySettings(item, settings);
+ getDefaultPlaySettings(item, settings, undefined, options);
return {
defaultMediaSource: mediaSource,
@@ -21,6 +29,6 @@ const useDefaultPlaySettings = (
defaultSubtitleIndex: subtitleIndex,
defaultBitrate: bitrate,
};
- }, [item, settings]);
+ }, [item, settings, options]);
export default useDefaultPlaySettings;
diff --git a/hooks/useItemQuery.ts b/hooks/useItemQuery.ts
index 370b5f35..d98f6193 100644
--- a/hooks/useItemQuery.ts
+++ b/hooks/useItemQuery.ts
@@ -2,6 +2,7 @@ import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
+import { Platform } from "react-native";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -49,6 +50,8 @@ export const useItemQuery = (
return response.data.Items?.[0];
},
enabled: !!itemId,
+ staleTime: isOffline ? Infinity : 60 * 1000,
+ refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
diff --git a/hooks/useRemoteSubtitles.ts b/hooks/useRemoteSubtitles.ts
index bc5b83e7..b101aeee 100644
--- a/hooks/useRemoteSubtitles.ts
+++ b/hooks/useRemoteSubtitles.ts
@@ -7,7 +7,12 @@ import { useMutation } from "@tanstack/react-query";
import { Directory, File, Paths } from "expo-file-system";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
+import { Platform } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
+import {
+ addDownloadedSubtitle,
+ type DownloadedSubtitle,
+} from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import {
OpenSubtitlesApi,
@@ -185,32 +190,70 @@ export function useRemoteSubtitles({
/**
* Download subtitle via OpenSubtitles API (returns local file path)
+ *
+ * On TV: Downloads to cache directory and persists metadata in MMKV
+ * On mobile: Downloads to cache directory (ephemeral, no persistence)
+ *
+ * Uses a flat filename structure with itemId prefix to avoid tvOS permission issues
*/
const downloadOpenSubtitles = useCallback(
- async (fileId: number): Promise => {
+ async (
+ fileId: number,
+ result: SubtitleSearchResult,
+ ): Promise<{ path: string; subtitle?: DownloadedSubtitle }> => {
if (!openSubtitlesApi) {
throw new Error("OpenSubtitles API key not configured");
}
// Get download link
const response = await openSubtitlesApi.download(fileId);
+ const originalFileName = response.file_name || `subtitle_${fileId}.srt`;
- // Download to cache directory
- const fileName = response.file_name || `subtitle_${fileId}.srt`;
- const subtitlesDir = new Directory(Paths.cache, "subtitles");
+ // Use cache directory for both platforms (tvOS has permission issues with documents)
+ // TV: Uses itemId prefix for organization and persists metadata
+ // Mobile: Simple filename, no persistence
+ const subtitlesDir = new Directory(Paths.cache, "streamyfin-subtitles");
// Ensure directory exists
if (!subtitlesDir.exists) {
- subtitlesDir.create({ intermediates: true });
+ subtitlesDir.create();
}
+ // TV: Prefix filename with itemId for organization
+ // Mobile: Use original filename
+ const fileName = Platform.isTV
+ ? `${itemId}_${originalFileName}`
+ : originalFileName;
+
// Create file and download
const destination = new File(subtitlesDir, fileName);
+
+ // Delete existing file if it exists (re-download)
+ if (destination.exists) {
+ destination.delete();
+ }
+
await File.downloadFileAsync(response.link, destination);
- return destination.uri;
+ // TV: Persist metadata for future sessions
+ if (Platform.isTV) {
+ const subtitleMetadata: DownloadedSubtitle = {
+ id: result.id,
+ itemId,
+ filePath: destination.uri,
+ name: result.name,
+ language: result.language,
+ format: result.format,
+ source: "opensubtitles",
+ downloadedAt: Date.now(),
+ };
+ addDownloadedSubtitle(subtitleMetadata);
+ return { path: destination.uri, subtitle: subtitleMetadata };
+ }
+
+ return { path: destination.uri };
},
- [openSubtitlesApi],
+ [openSubtitlesApi, itemId],
);
/**
@@ -257,8 +300,11 @@ export function useRemoteSubtitles({
return { type: "server" as const };
}
if (result.fileId) {
- const localPath = await downloadOpenSubtitles(result.fileId);
- return { type: "local" as const, path: localPath };
+ const { path, subtitle } = await downloadOpenSubtitles(
+ result.fileId,
+ result,
+ );
+ return { type: "local" as const, path, subtitle };
}
throw new Error("Invalid subtitle result");
},
diff --git a/hooks/useTVAccountActionModal.ts b/hooks/useTVAccountActionModal.ts
new file mode 100644
index 00000000..97db7ac5
--- /dev/null
+++ b/hooks/useTVAccountActionModal.ts
@@ -0,0 +1,34 @@
+import { useCallback } from "react";
+import useRouter from "@/hooks/useAppRouter";
+import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
+import type {
+ SavedServer,
+ SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { store } from "@/utils/store";
+
+interface ShowAccountActionModalParams {
+ server: SavedServer;
+ account: SavedServerAccount;
+ onLogin: () => void;
+ onDelete: () => void;
+}
+
+export const useTVAccountActionModal = () => {
+ const router = useRouter();
+
+ const showAccountActionModal = useCallback(
+ (params: ShowAccountActionModalParams) => {
+ store.set(tvAccountActionModalAtom, {
+ server: params.server,
+ account: params.account,
+ onLogin: params.onLogin,
+ onDelete: params.onDelete,
+ });
+ router.push("/tv-account-action-modal");
+ },
+ [router],
+ );
+
+ return { showAccountActionModal };
+};
diff --git a/hooks/useTVAccountSelectModal.ts b/hooks/useTVAccountSelectModal.ts
new file mode 100644
index 00000000..3bc61ed7
--- /dev/null
+++ b/hooks/useTVAccountSelectModal.ts
@@ -0,0 +1,34 @@
+import { useCallback } from "react";
+import useRouter from "@/hooks/useAppRouter";
+import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
+import type {
+ SavedServer,
+ SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { store } from "@/utils/store";
+
+interface ShowAccountSelectModalParams {
+ server: SavedServer;
+ onAccountAction: (account: SavedServerAccount) => void;
+ onAddAccount: () => void;
+ onDeleteServer: () => void;
+}
+
+export const useTVAccountSelectModal = () => {
+ const router = useRouter();
+
+ const showAccountSelectModal = useCallback(
+ (params: ShowAccountSelectModalParams) => {
+ store.set(tvAccountSelectModalAtom, {
+ server: params.server,
+ onAccountAction: params.onAccountAction,
+ onAddAccount: params.onAddAccount,
+ onDeleteServer: params.onDeleteServer,
+ });
+ router.push("/tv-account-select-modal");
+ },
+ [router],
+ );
+
+ return { showAccountSelectModal };
+};
diff --git a/hooks/useTVBackHandler.ts b/hooks/useTVBackHandler.ts
new file mode 100644
index 00000000..dd5ca106
--- /dev/null
+++ b/hooks/useTVBackHandler.ts
@@ -0,0 +1,160 @@
+import { useNavigation } from "@react-navigation/native";
+import { router, useSegments } from "expo-router";
+import { useEffect, useRef } from "react";
+import { BackHandler, Platform } from "react-native";
+
+// TV event handler and control with fallback for non-TV platforms
+let useTVEventHandler: (callback: (evt: any) => void) => void;
+let TVEventControl: {
+ enableTVMenuKey: () => void;
+ disableTVMenuKey: () => void;
+} | null = null;
+
+if (Platform.isTV) {
+ try {
+ useTVEventHandler = require("react-native").useTVEventHandler;
+ TVEventControl = require("react-native").TVEventControl;
+ } catch {
+ useTVEventHandler = () => {};
+ }
+} else {
+ useTVEventHandler = () => {};
+}
+
+/**
+ * Check if we're at the root of a tab
+ */
+function isAtTabRoot(segments: string[]): boolean {
+ const lastSegment = segments[segments.length - 1];
+ const tabNames = [
+ "(home)",
+ "(search)",
+ "(favorites)",
+ "(libraries)",
+ "(watchlists)",
+ "(settings)",
+ "(custom-links)",
+ ];
+ return tabNames.includes(lastSegment) || lastSegment === "index";
+}
+
+/**
+ * Get the current tab name from segments
+ */
+function getCurrentTab(segments: string[]): string | undefined {
+ return segments.find(
+ (s) =>
+ s === "(home)" ||
+ s === "(search)" ||
+ s === "(favorites)" ||
+ s === "(libraries)" ||
+ s === "(watchlists)" ||
+ s === "(settings)" ||
+ s === "(custom-links)",
+ );
+}
+
+/**
+ * Hook to handle TV back/menu button presses.
+ *
+ * Behavior:
+ * - On home tab at root: allows app to exit (default tvOS behavior)
+ * - On other tabs at root: navigates to home tab
+ * - Deeper in navigation stack: goes back
+ */
+export function useTVBackHandler() {
+ const navigation = useNavigation();
+ const segments = useSegments();
+ const lastMenuKeyState = useRef(null);
+
+ // Get current state
+ const currentTab = getCurrentTab(segments);
+ const atTabRoot = isAtTabRoot(segments);
+ const isOnHomeRoot = atTabRoot && currentTab === "(home)";
+
+ // Toggle menu key interception based on current location
+ useEffect(() => {
+ if (!Platform.isTV || !TVEventControl) return;
+
+ if (isOnHomeRoot) {
+ // On home tab root - disable interception to allow app exit
+ if (lastMenuKeyState.current !== false) {
+ TVEventControl.disableTVMenuKey();
+ lastMenuKeyState.current = false;
+ }
+ } else {
+ // On other screens - enable interception to handle navigation
+ if (lastMenuKeyState.current !== true) {
+ TVEventControl.enableTVMenuKey();
+ lastMenuKeyState.current = true;
+ }
+ }
+ }, [isOnHomeRoot]);
+
+ // Handle TV remote menu/back button events
+ useTVEventHandler((evt) => {
+ if (!evt) return;
+ if (evt.eventType === "menu" || evt.eventType === "back") {
+ // If on home root, let the default behavior happen (app exit)
+ if (isOnHomeRoot) {
+ return;
+ }
+
+ // If at tab root level (but not home), navigate to home
+ if (atTabRoot) {
+ router.navigate("/(auth)/(tabs)/(home)");
+ return;
+ }
+
+ // Not at tab root - go back in the stack
+ if (navigation.canGoBack()) {
+ navigation.goBack();
+ return;
+ }
+
+ // Fallback: navigate to home
+ router.navigate("/(auth)/(tabs)/(home)");
+ }
+ });
+
+ // Android TV BackHandler
+ useEffect(() => {
+ if (!Platform.isTV) return;
+
+ const handleBackPress = () => {
+ // If on home root, allow app to exit
+ if (isOnHomeRoot) {
+ return false; // Don't prevent default (allows exit)
+ }
+
+ if (atTabRoot) {
+ router.navigate("/(auth)/(tabs)/(home)");
+ return true;
+ }
+
+ if (navigation.canGoBack()) {
+ navigation.goBack();
+ return true;
+ }
+
+ router.navigate("/(auth)/(tabs)/(home)");
+ return true;
+ };
+
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ handleBackPress,
+ );
+
+ return () => subscription.remove();
+ }, [navigation, isOnHomeRoot, atTabRoot]);
+}
+
+/**
+ * Call this at app startup to enable TV menu key interception.
+ */
+export function enableTVMenuKeyInterception() {
+ if (Platform.isTV && TVEventControl) {
+ TVEventControl.enableTVMenuKey();
+ }
+}
diff --git a/hooks/useTVItemActionModal.ts b/hooks/useTVItemActionModal.ts
new file mode 100644
index 00000000..3c547c0d
--- /dev/null
+++ b/hooks/useTVItemActionModal.ts
@@ -0,0 +1,82 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { useQueryClient } from "@tanstack/react-query";
+import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert } from "react-native";
+import { usePlaybackManager } from "@/hooks/usePlaybackManager";
+import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
+
+export const useTVItemActionModal = () => {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const { markItemPlayed, markItemUnplayed } = usePlaybackManager();
+ const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache();
+
+ const showItemActions = useCallback(
+ (item: BaseItemDto) => {
+ const isPlayed = item.UserData?.Played ?? false;
+ const itemTitle =
+ item.Type === "Episode"
+ ? `${item.SeriesName} - ${item.Name}`
+ : (item.Name ?? "");
+
+ const actionLabel = isPlayed
+ ? t("item_card.mark_unplayed")
+ : t("item_card.mark_played");
+
+ Alert.alert(itemTitle, undefined, [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: actionLabel,
+ onPress: async () => {
+ if (!item.Id) return;
+
+ // Optimistic update
+ queryClient.setQueriesData(
+ { queryKey: ["item", item.Id] },
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ UserData: {
+ ...old.UserData,
+ Played: !isPlayed,
+ PlaybackPositionTicks: 0,
+ PlayedPercentage: 0,
+ },
+ };
+ },
+ );
+
+ try {
+ if (!isPlayed) {
+ await markItemPlayed(item.Id);
+ } else {
+ await markItemUnplayed(item.Id);
+ }
+ } catch {
+ // Revert on failure
+ queryClient.invalidateQueries({
+ queryKey: ["item", item.Id],
+ });
+ } finally {
+ await invalidatePlaybackProgressCache();
+ queryClient.invalidateQueries({
+ queryKey: ["item", item.Id],
+ });
+ }
+ },
+ },
+ ]);
+ },
+ [
+ t,
+ queryClient,
+ markItemPlayed,
+ markItemUnplayed,
+ invalidatePlaybackProgressCache,
+ ],
+ );
+
+ return { showItemActions };
+};
diff --git a/hooks/useTVThemeMusic.ts b/hooks/useTVThemeMusic.ts
new file mode 100644
index 00000000..0a9b1dcc
--- /dev/null
+++ b/hooks/useTVThemeMusic.ts
@@ -0,0 +1,232 @@
+import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import type { Audio as AudioType } from "expo-av";
+import { Audio } from "expo-av";
+import { useAtom } from "jotai";
+import { useEffect } from "react";
+import { Platform } from "react-native";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+
+const TARGET_VOLUME = 0.3;
+const FADE_IN_DURATION = 2000;
+const FADE_OUT_DURATION = 1000;
+const FADE_STEP_MS = 50;
+
+/**
+ * Smoothly transitions audio volume from `from` to `to` over `duration` ms.
+ * Returns a cleanup function that cancels the fade.
+ */
+function fadeVolume(
+ sound: AudioType.Sound,
+ from: number,
+ to: number,
+ duration: number,
+): { promise: Promise; cancel: () => void } {
+ let cancelled = false;
+ const cancel = () => {
+ cancelled = true;
+ };
+
+ const steps = Math.max(1, Math.floor(duration / FADE_STEP_MS));
+ const delta = (to - from) / steps;
+
+ const promise = new Promise((resolve) => {
+ let current = from;
+ let step = 0;
+
+ const tick = () => {
+ if (cancelled || step >= steps) {
+ if (!cancelled) {
+ sound.setVolumeAsync(to).catch(() => {});
+ }
+ resolve();
+ return;
+ }
+ step++;
+ current += delta;
+ sound
+ .setVolumeAsync(Math.max(0, Math.min(1, current)))
+ .catch(() => {})
+ .then(() => {
+ if (!cancelled) {
+ setTimeout(tick, FADE_STEP_MS);
+ } else {
+ resolve();
+ }
+ });
+ };
+
+ tick();
+ });
+
+ return { promise, cancel };
+}
+
+// --- Module-level singleton state ---
+let sharedSound: AudioType.Sound | null = null;
+let currentSongId: string | null = null;
+let ownerCount = 0;
+let activeFade: { cancel: () => void } | null = null;
+let cleanupPromise: Promise | null = null;
+
+/** Fade out, stop, and unload the shared sound. */
+async function teardownSharedSound(): Promise {
+ const sound = sharedSound;
+ if (!sound) return;
+
+ activeFade?.cancel();
+ activeFade = null;
+
+ try {
+ const status = await sound.getStatusAsync();
+ if (status.isLoaded) {
+ const currentVolume = status.volume ?? TARGET_VOLUME;
+ const fade = fadeVolume(sound, currentVolume, 0, FADE_OUT_DURATION);
+ activeFade = fade;
+ await fade.promise;
+ activeFade = null;
+ await sound.stopAsync();
+ await sound.unloadAsync();
+ }
+ } catch {
+ try {
+ await sound.unloadAsync();
+ } catch {
+ // ignore
+ }
+ }
+
+ if (sharedSound === sound) {
+ sharedSound = null;
+ currentSongId = null;
+ }
+}
+
+/** Begin cleanup idempotently; returns the shared promise. */
+function beginCleanup(): Promise {
+ if (!cleanupPromise) {
+ cleanupPromise = teardownSharedSound().finally(() => {
+ cleanupPromise = null;
+ });
+ }
+ return cleanupPromise;
+}
+
+export function useTVThemeMusic(itemId: string | undefined) {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const { settings } = useSettings();
+
+ const enabled =
+ Platform.isTV &&
+ !!api &&
+ !!user?.Id &&
+ !!itemId &&
+ settings.tvThemeMusicEnabled;
+
+ // Fetch theme songs
+ const { data: themeSongs } = useQuery({
+ queryKey: ["themeSongs", itemId],
+ queryFn: async () => {
+ const result = await getLibraryApi(api!).getThemeSongs({
+ itemId: itemId!,
+ userId: user!.Id!,
+ inheritFromParent: true,
+ });
+ return result.data;
+ },
+ enabled,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ // Load and play audio when theme songs are available and enabled
+ useEffect(() => {
+ if (!enabled || !themeSongs?.Items?.length || !api) {
+ return;
+ }
+
+ const themeItem = themeSongs.Items[0];
+ const songId = themeItem.Id!;
+
+ ownerCount++;
+ let mounted = true;
+
+ const startPlayback = async () => {
+ // If the same song is already playing, keep it going
+ if (currentSongId === songId && sharedSound) {
+ return;
+ }
+
+ // If a different song is playing (or cleanup is in progress), tear it down first
+ if (sharedSound || cleanupPromise) {
+ activeFade?.cancel();
+ activeFade = null;
+ await beginCleanup();
+ }
+
+ if (!mounted) return;
+
+ const sound = new Audio.Sound();
+ sharedSound = sound;
+ currentSongId = songId;
+
+ try {
+ await Audio.setAudioModeAsync({
+ playsInSilentModeIOS: true,
+ staysActiveInBackground: false,
+ });
+
+ const params = new URLSearchParams({
+ UserId: user!.Id!,
+ DeviceId: api.deviceInfo.id ?? "",
+ MaxStreamingBitrate: "140000000",
+ Container: "mp3,aac,m4a|aac,m4b|aac,flac,wav",
+ TranscodingContainer: "mp4",
+ TranscodingProtocol: "http",
+ AudioCodec: "aac",
+ ApiKey: api.accessToken ?? "",
+ EnableRedirection: "true",
+ EnableRemoteMedia: "false",
+ });
+ const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`;
+ await sound.loadAsync({ uri: url });
+
+ if (!mounted || sharedSound !== sound) {
+ await sound.unloadAsync();
+ return;
+ }
+
+ await sound.setIsLoopingAsync(true);
+ await sound.setVolumeAsync(0);
+ await sound.playAsync();
+
+ if (mounted && sharedSound === sound) {
+ const fade = fadeVolume(sound, 0, TARGET_VOLUME, FADE_IN_DURATION);
+ activeFade = fade;
+ await fade.promise;
+ activeFade = null;
+ }
+ } catch (e) {
+ console.warn("Theme music playback error:", e);
+ }
+ };
+
+ startPlayback();
+
+ // Cleanup: decrement owner count, defer teardown check
+ return () => {
+ mounted = false;
+ ownerCount--;
+
+ // Defer the check so React can finish processing both unmount + mount
+ // in the same commit. If another instance mounts (same song), ownerCount
+ // will be back to >0 and we skip teardown entirely.
+ setTimeout(() => {
+ if (ownerCount === 0) {
+ beginCleanup();
+ }
+ }, 0);
+ };
+ }, [enabled, themeSongs, api]);
+}
diff --git a/hooks/useTVUserSwitchModal.ts b/hooks/useTVUserSwitchModal.ts
new file mode 100644
index 00000000..a0b0a944
--- /dev/null
+++ b/hooks/useTVUserSwitchModal.ts
@@ -0,0 +1,42 @@
+import { useCallback } from "react";
+import useRouter from "@/hooks/useAppRouter";
+import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
+import type {
+ SavedServer,
+ SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { store } from "@/utils/store";
+
+interface UseTVUserSwitchModalOptions {
+ onAccountSelect: (account: SavedServerAccount) => void;
+}
+
+export function useTVUserSwitchModal() {
+ const router = useRouter();
+
+ const showUserSwitchModal = useCallback(
+ (
+ server: SavedServer,
+ currentUserId: string,
+ options: UseTVUserSwitchModalOptions,
+ ) => {
+ // Need at least 2 accounts (current + at least one other)
+ if (server.accounts.length < 2) {
+ return;
+ }
+
+ store.set(tvUserSwitchModalAtom, {
+ serverUrl: server.address,
+ serverName: server.name || server.address,
+ accounts: server.accounts,
+ currentUserId,
+ onAccountSelect: options.onAccountSelect,
+ });
+
+ router.push("/(auth)/tv-user-switch-modal");
+ },
+ [router],
+ );
+
+ return { showUserSwitchModal };
+}
diff --git a/modules/glass-poster/expo-module.config.json b/modules/glass-poster/expo-module.config.json
new file mode 100644
index 00000000..9c325f52
--- /dev/null
+++ b/modules/glass-poster/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["apple"],
+ "apple": {
+ "modules": ["GlassPosterModule"]
+ }
+}
diff --git a/modules/glass-poster/index.ts b/modules/glass-poster/index.ts
new file mode 100644
index 00000000..f448ad09
--- /dev/null
+++ b/modules/glass-poster/index.ts
@@ -0,0 +1,8 @@
+// Glass Poster - Native SwiftUI glass effect for tvOS 26+
+
+export * from "./src/GlassPoster.types";
+export {
+ default as GlassPosterModule,
+ isGlassEffectAvailable,
+} from "./src/GlassPosterModule";
+export { default as GlassPosterView } from "./src/GlassPosterView";
diff --git a/modules/glass-poster/ios/GlassPoster.podspec b/modules/glass-poster/ios/GlassPoster.podspec
new file mode 100644
index 00000000..60e5af69
--- /dev/null
+++ b/modules/glass-poster/ios/GlassPoster.podspec
@@ -0,0 +1,23 @@
+Pod::Spec.new do |s|
+ s.name = 'GlassPoster'
+ s.version = '1.0.0'
+ s.summary = 'Native SwiftUI glass effect poster for tvOS'
+ s.description = 'Provides Liquid Glass effect poster cards for tvOS 26+'
+ s.author = 'Streamyfin'
+ s.homepage = 'https://github.com/streamyfin/streamyfin'
+ s.platforms = {
+ :ios => '15.1',
+ :tvos => '15.1'
+ }
+ s.source = { git: '' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_VERSION' => '5.9'
+ }
+
+ s.source_files = "*.{h,m,mm,swift}"
+end
diff --git a/modules/glass-poster/ios/GlassPosterExpoView.swift b/modules/glass-poster/ios/GlassPosterExpoView.swift
new file mode 100644
index 00000000..d2d654c5
--- /dev/null
+++ b/modules/glass-poster/ios/GlassPosterExpoView.swift
@@ -0,0 +1,94 @@
+import ExpoModulesCore
+import SwiftUI
+import UIKit
+import Combine
+
+/// Observable state that SwiftUI can watch for changes without rebuilding the entire view
+class GlassPosterState: ObservableObject {
+ @Published var imageUrl: String? = nil
+ @Published var aspectRatio: Double = 10.0 / 15.0
+ @Published var cornerRadius: Double = 24
+ @Published var progress: Double = 0
+ @Published var showWatchedIndicator: Bool = false
+ @Published var isFocused: Bool = false
+ @Published var width: Double = 260
+}
+
+/// ExpoView wrapper that hosts the SwiftUI GlassPosterView
+class GlassPosterExpoView: ExpoView {
+ private var hostingController: UIHostingController?
+ private let state = GlassPosterState()
+
+ // Stored dimensions for intrinsic content size
+ private var posterWidth: CGFloat = 260
+ private var posterAspectRatio: CGFloat = 10.0 / 15.0
+
+ // Event dispatchers
+ let onLoad = EventDispatcher()
+ let onError = EventDispatcher()
+
+ required init(appContext: AppContext? = nil) {
+ super.init(appContext: appContext)
+ setupHostingController()
+ }
+
+ private func setupHostingController() {
+ let wrapper = GlassPosterViewWrapper(state: state)
+ let hostingController = UIHostingController(rootView: wrapper)
+ hostingController.view.backgroundColor = .clear
+ hostingController.view.translatesAutoresizingMaskIntoConstraints = false
+
+ addSubview(hostingController.view)
+
+ NSLayoutConstraint.activate([
+ hostingController.view.topAnchor.constraint(equalTo: topAnchor),
+ hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
+ hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
+ hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor)
+ ])
+
+ self.hostingController = hostingController
+ }
+
+ // Override intrinsic content size for proper React Native layout
+ override var intrinsicContentSize: CGSize {
+ let height = posterWidth / posterAspectRatio
+ return CGSize(width: posterWidth, height: height)
+ }
+
+ // MARK: - Property Setters
+ // These now update the observable state object directly.
+ // SwiftUI observes state changes and only re-renders affected views.
+
+ func setImageUrl(_ url: String?) {
+ state.imageUrl = url
+ }
+
+ func setAspectRatio(_ ratio: Double) {
+ state.aspectRatio = ratio
+ posterAspectRatio = CGFloat(ratio)
+ invalidateIntrinsicContentSize()
+ }
+
+ func setWidth(_ width: Double) {
+ state.width = width
+ posterWidth = CGFloat(width)
+ invalidateIntrinsicContentSize()
+ }
+
+ func setCornerRadius(_ radius: Double) {
+ state.cornerRadius = radius
+ }
+
+ func setProgress(_ progress: Double) {
+ state.progress = progress
+ }
+
+ func setShowWatchedIndicator(_ show: Bool) {
+ state.showWatchedIndicator = show
+ }
+
+ func setIsFocused(_ focused: Bool) {
+ state.isFocused = focused
+ }
+}
diff --git a/modules/glass-poster/ios/GlassPosterModule.swift b/modules/glass-poster/ios/GlassPosterModule.swift
new file mode 100644
index 00000000..3b9b9b19
--- /dev/null
+++ b/modules/glass-poster/ios/GlassPosterModule.swift
@@ -0,0 +1,50 @@
+import ExpoModulesCore
+
+public class GlassPosterModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("GlassPoster")
+
+ // Check if glass effect is available (tvOS 26+)
+ Function("isGlassEffectAvailable") { () -> Bool in
+ #if os(tvOS)
+ if #available(tvOS 26.0, *) {
+ return true
+ }
+ #endif
+ return false
+ }
+
+ // Native view component
+ View(GlassPosterExpoView.self) {
+ Prop("imageUrl") { (view: GlassPosterExpoView, url: String?) in
+ view.setImageUrl(url)
+ }
+
+ Prop("aspectRatio") { (view: GlassPosterExpoView, ratio: Double) in
+ view.setAspectRatio(ratio)
+ }
+
+ Prop("cornerRadius") { (view: GlassPosterExpoView, radius: Double) in
+ view.setCornerRadius(radius)
+ }
+
+ Prop("progress") { (view: GlassPosterExpoView, progress: Double) in
+ view.setProgress(progress)
+ }
+
+ Prop("showWatchedIndicator") { (view: GlassPosterExpoView, show: Bool) in
+ view.setShowWatchedIndicator(show)
+ }
+
+ Prop("isFocused") { (view: GlassPosterExpoView, focused: Bool) in
+ view.setIsFocused(focused)
+ }
+
+ Prop("width") { (view: GlassPosterExpoView, width: Double) in
+ view.setWidth(width)
+ }
+
+ Events("onLoad", "onError")
+ }
+ }
+}
diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift
new file mode 100644
index 00000000..8c8e4f5f
--- /dev/null
+++ b/modules/glass-poster/ios/GlassPosterView.swift
@@ -0,0 +1,219 @@
+import SwiftUI
+
+/// Wrapper view that observes state changes from GlassPosterState
+/// This allows SwiftUI to efficiently update only the changed properties
+/// instead of rebuilding the entire view hierarchy on every prop change.
+struct GlassPosterViewWrapper: View {
+ @ObservedObject var state: GlassPosterState
+
+ var body: some View {
+ GlassPosterView(
+ imageUrl: state.imageUrl,
+ aspectRatio: state.aspectRatio,
+ cornerRadius: state.cornerRadius,
+ progress: state.progress,
+ showWatchedIndicator: state.showWatchedIndicator,
+ isFocused: state.isFocused,
+ width: state.width
+ )
+ }
+}
+
+/// SwiftUI view with tvOS 26 Liquid Glass effect
+struct GlassPosterView: View {
+ var imageUrl: String? = nil
+ var aspectRatio: Double = 10.0 / 15.0
+ var cornerRadius: Double = 24
+ var progress: Double = 0
+ var showWatchedIndicator: Bool = false
+ var isFocused: Bool = false
+ var width: Double = 260
+
+ // Internal focus state for tvOS
+ @FocusState private var isInternallyFocused: Bool
+
+ // Combined focus state (external prop OR internal focus)
+ private var isCurrentlyFocused: Bool {
+ isFocused || isInternallyFocused
+ }
+
+ // Calculated height based on width and aspect ratio
+ private var height: Double {
+ width / aspectRatio
+ }
+
+ var body: some View {
+ #if os(tvOS)
+ if #available(tvOS 26.0, *) {
+ glassContent
+ } else {
+ fallbackContent
+ }
+ #else
+ fallbackContent
+ #endif
+ }
+
+ // MARK: - tvOS 26+ Content (glass effect disabled for now)
+
+ #if os(tvOS)
+ @available(tvOS 26.0, *)
+ private var glassContent: some View {
+ return ZStack {
+ // Image content
+ imageContent
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
+
+ // White border on focus
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .stroke(Color.white, lineWidth: isCurrentlyFocused ? 4 : 0)
+
+ // Progress bar overlay
+ if progress > 0 {
+ progressOverlay
+ }
+
+ // Watched indicator
+ if showWatchedIndicator {
+ watchedIndicatorOverlay
+ }
+ }
+ .frame(width: width, height: height)
+ .focusable()
+ .focused($isInternallyFocused)
+ .scaleEffect(isCurrentlyFocused ? 1.05 : 1.0)
+ .animation(.easeOut(duration: 0.15), value: isCurrentlyFocused)
+ }
+ #endif
+
+ // MARK: - Fallback for older tvOS versions
+
+ private var fallbackContent: some View {
+ ZStack {
+ // Main image
+ imageContent
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
+
+ // White border on focus
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .stroke(Color.white, lineWidth: isFocused ? 4 : 0)
+
+ // Progress bar overlay
+ if progress > 0 {
+ progressOverlay
+ }
+
+ // Watched indicator
+ if showWatchedIndicator {
+ watchedIndicatorOverlay
+ }
+ }
+ .frame(width: width, height: height)
+ .scaleEffect(isFocused ? 1.05 : 1.0)
+ .animation(.easeOut(duration: 0.15), value: isFocused)
+ }
+
+ // MARK: - Shared Components
+
+ private var imageContent: some View {
+ Group {
+ if let urlString = imageUrl, let url = URL(string: urlString) {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .empty:
+ placeholderView
+ case .success(let image):
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: width, height: height)
+ .clipped()
+ case .failure:
+ placeholderView
+ @unknown default:
+ placeholderView
+ }
+ }
+ } else {
+ placeholderView
+ }
+ }
+ }
+
+ private var placeholderView: some View {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ }
+
+ private var progressOverlay: some View {
+ VStack {
+ Spacer()
+ GeometryReader { geometry in
+ ZStack(alignment: .leading) {
+ // Background track
+ Rectangle()
+ .fill(Color.white.opacity(0.3))
+ .frame(height: 4)
+
+ // Progress fill
+ Rectangle()
+ .fill(Color.white)
+ .frame(width: geometry.size.width * CGFloat(progress / 100), height: 4)
+ }
+ }
+ .frame(height: 4)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
+ }
+
+ private var watchedIndicatorOverlay: some View {
+ VStack {
+ HStack {
+ Spacer()
+ ZStack {
+ Circle()
+ .fill(Color.white.opacity(0.9))
+ .frame(width: 28, height: 28)
+
+ Image(systemName: "checkmark")
+ .font(.system(size: 14, weight: .bold))
+ .foregroundColor(.black)
+ }
+ .padding(8)
+ }
+ Spacer()
+ }
+ }
+}
+
+// MARK: - Preview
+
+#if DEBUG
+struct GlassPosterView_Previews: PreviewProvider {
+ static var previews: some View {
+ VStack(spacing: 20) {
+ GlassPosterView(
+ imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
+ aspectRatio: 10.0 / 15.0,
+ cornerRadius: 24,
+ progress: 45,
+ showWatchedIndicator: false,
+ isFocused: true,
+ width: 260
+ )
+
+ GlassPosterView(
+ imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
+ aspectRatio: 16.0 / 9.0,
+ cornerRadius: 24,
+ progress: 75,
+ showWatchedIndicator: true,
+ isFocused: false,
+ width: 400
+ )
+ }
+ .padding()
+ .background(Color.black)
+ }
+}
+#endif
diff --git a/modules/glass-poster/src/GlassPoster.types.ts b/modules/glass-poster/src/GlassPoster.types.ts
new file mode 100644
index 00000000..8878779b
--- /dev/null
+++ b/modules/glass-poster/src/GlassPoster.types.ts
@@ -0,0 +1,26 @@
+import type { StyleProp, ViewStyle } from "react-native";
+
+export interface GlassPosterViewProps {
+ /** URL of the image to display */
+ imageUrl: string | null;
+ /** Aspect ratio of the poster (width/height). Default: 10/15 for portrait, 16/9 for landscape */
+ aspectRatio: number;
+ /** Corner radius in points. Default: 24 */
+ cornerRadius: number;
+ /** Progress percentage (0-100). Shows progress bar at bottom when > 0 */
+ progress: number;
+ /** Whether to show the watched checkmark indicator */
+ showWatchedIndicator: boolean;
+ /** Whether the poster is currently focused (for scale animation) */
+ isFocused: boolean;
+ /** Width of the poster in points. Required for proper sizing. */
+ width: number;
+ /** Style for the container view */
+ style?: StyleProp;
+ /** Called when the image loads successfully */
+ onLoad?: () => void;
+ /** Called when image loading fails */
+ onError?: (error: string) => void;
+}
+
+export type GlassPosterModuleEvents = Record;
diff --git a/modules/glass-poster/src/GlassPosterModule.ts b/modules/glass-poster/src/GlassPosterModule.ts
new file mode 100644
index 00000000..20c2714f
--- /dev/null
+++ b/modules/glass-poster/src/GlassPosterModule.ts
@@ -0,0 +1,43 @@
+import { NativeModule, requireNativeModule } from "expo";
+import { Platform } from "react-native";
+
+import type { GlassPosterModuleEvents } from "./GlassPoster.types";
+
+declare class GlassPosterModuleType extends NativeModule {
+ isGlassEffectAvailable(): boolean;
+}
+
+// Only load the native module on tvOS
+let GlassPosterNativeModule: GlassPosterModuleType | null = null;
+
+if (Platform.OS === "ios" && Platform.isTV) {
+ try {
+ GlassPosterNativeModule =
+ requireNativeModule("GlassPoster");
+ } catch {
+ // Module not available, will use fallback
+ }
+}
+
+/**
+ * Check if the native glass effect is available (tvOS 26+)
+ * NOTE: Glass effect is currently disabled for performance reasons.
+ * The native module rebuilds views on every focus change which causes lag.
+ * Re-enable by uncommenting the native module check below.
+ */
+export function isGlassEffectAvailable(): boolean {
+ // Glass effect disabled - using JS-based focus effects instead
+ return false;
+
+ // Original implementation (re-enable when glass effect is optimized):
+ // if (!GlassPosterNativeModule) {
+ // return false;
+ // }
+ // try {
+ // return GlassPosterNativeModule.isGlassEffectAvailable();
+ // } catch {
+ // return false;
+ // }
+}
+
+export default GlassPosterNativeModule;
diff --git a/modules/glass-poster/src/GlassPosterView.tsx b/modules/glass-poster/src/GlassPosterView.tsx
new file mode 100644
index 00000000..0ec104f5
--- /dev/null
+++ b/modules/glass-poster/src/GlassPosterView.tsx
@@ -0,0 +1,46 @@
+import { requireNativeView } from "expo";
+import * as React from "react";
+import { Platform, View } from "react-native";
+
+import type { GlassPosterViewProps } from "./GlassPoster.types";
+import { isGlassEffectAvailable } from "./GlassPosterModule";
+
+// Only require the native view on tvOS
+let NativeGlassPosterView: React.ComponentType | null =
+ null;
+
+if (Platform.OS === "ios" && Platform.isTV) {
+ try {
+ NativeGlassPosterView =
+ requireNativeView("GlassPoster");
+ } catch {
+ // Module not available
+ }
+}
+
+/**
+ * GlassPosterView - Native SwiftUI glass effect poster for tvOS 26+
+ *
+ * On tvOS 26+: Renders with native Liquid Glass effect
+ * On older tvOS: Renders with subtle glass-like material effect
+ * On other platforms: Returns null (use existing poster components)
+ */
+const GlassPosterView: React.FC = (props) => {
+ // Only render on tvOS
+ if (!Platform.isTV || Platform.OS !== "ios") {
+ return null;
+ }
+
+ // Use native view if available
+ if (NativeGlassPosterView) {
+ return ;
+ }
+
+ // Fallback: return empty view (caller should handle this)
+ return ;
+};
+
+export default GlassPosterView;
+
+// Re-export availability check for convenience
+export { isGlassEffectAvailable };
diff --git a/modules/glass-poster/src/index.ts b/modules/glass-poster/src/index.ts
new file mode 100644
index 00000000..eee2be16
--- /dev/null
+++ b/modules/glass-poster/src/index.ts
@@ -0,0 +1,6 @@
+export * from "./GlassPoster.types";
+export {
+ default as GlassPosterModule,
+ isGlassEffectAvailable,
+} from "./GlassPosterModule";
+export { default as GlassPosterView } from "./GlassPosterView";
diff --git a/modules/index.ts b/modules/index.ts
index e026be73..d93e9077 100644
--- a/modules/index.ts
+++ b/modules/index.ts
@@ -7,7 +7,9 @@ export type {
DownloadStartedEvent,
} from "./background-downloader";
export { default as BackgroundDownloader } from "./background-downloader";
-
+// Glass Poster (tvOS 26+)
+export type { GlassPosterViewProps } from "./glass-poster";
+export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
// MPV Player (iOS + Android)
export type {
AudioTrack as MpvAudioTrack,
diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift
index af55826f..4d0f9577 100644
--- a/modules/mpv-player/ios/MPVLayerRenderer.swift
+++ b/modules/mpv-player/ios/MPVLayerRenderer.swift
@@ -195,7 +195,8 @@ final class MPVLayerRenderer {
// CRITICAL: This option MUST be set immediately after vo=avfoundation, before hwdec options.
// On tvOS, moving this elsewhere causes the app to freeze when exiting the player.
// - iOS: "yes" for PiP subtitle support (subtitles baked into video)
- // - tvOS: "no" to prevent gray tint + frame drops with subtitles
+ // - tvOS: "no" - composite OSD breaks subtitle rendering entirely on tvOS
+ // Note: This means subtitle styling (background colors) won't work on tvOS
#if os(tvOS)
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no"))
#else
@@ -220,6 +221,8 @@ final class MPVLayerRenderer {
#endif
// Subtitle and audio settings
+ checkError(mpv_set_option_string(mpv, "sub-scale-with-window", "no"))
+ checkError(mpv_set_option_string(mpv, "sub-use-margins", "no"))
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
@@ -300,7 +303,11 @@ final class MPVLayerRenderer {
startPosition: Double? = nil,
externalSubtitles: [String]? = nil,
initialSubtitleId: Int? = nil,
- initialAudioId: Int? = nil
+ initialAudioId: Int? = nil,
+ cacheEnabled: String? = nil,
+ cacheSeconds: Int? = nil,
+ demuxerMaxBytes: Int? = nil,
+ demuxerMaxBackBytes: Int? = nil
) {
currentPreset = preset
currentURL = url
@@ -323,6 +330,21 @@ final class MPVLayerRenderer {
// Stop previous playback before loading new file
self.command(handle, ["stop"])
self.updateHTTPHeaders(headers)
+
+ // Apply cache/buffer settings
+ if let cacheMode = cacheEnabled {
+ self.setProperty(name: "cache", value: cacheMode)
+ }
+ if let cacheSecs = cacheSeconds {
+ self.setProperty(name: "cache-secs", value: String(cacheSecs))
+ }
+ if let maxBytes = demuxerMaxBytes {
+ self.setProperty(name: "demuxer-max-bytes", value: "\(maxBytes)MiB")
+ }
+ if let maxBackBytes = demuxerMaxBackBytes {
+ self.setProperty(name: "demuxer-max-back-bytes", value: "\(maxBackBytes)MiB")
+ }
+
// Set start position
if let startPos = startPosition, startPos > 0 {
self.setProperty(name: "start", value: String(format: "%.2f", startPos))
@@ -799,7 +821,22 @@ final class MPVLayerRenderer {
func setSubtitleFontSize(_ size: Int) {
setProperty(name: "sub-font-size", value: String(size))
}
-
+
+ func setSubtitleBackgroundColor(_ color: String) {
+ setProperty(name: "sub-back-color", value: color)
+ }
+
+ func setSubtitleBorderStyle(_ style: String) {
+ // "outline-and-shadow" (default) or "background-box" (enables background color)
+ setProperty(name: "sub-border-style", value: style)
+ }
+
+ func setSubtitleAssOverride(_ mode: String) {
+ // Controls whether to override ASS subtitle styles
+ // "no" = keep ASS styles, "force" = override with user settings
+ setProperty(name: "sub-ass-override", value: mode)
+ }
+
// MARK: - Audio Track Controls
func getAudioTracks() -> [[String: Any]] {
diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift
index b60a3d40..08665e28 100644
--- a/modules/mpv-player/ios/MpvPlayerModule.swift
+++ b/modules/mpv-player/ios/MpvPlayerModule.swift
@@ -29,7 +29,10 @@ public class MpvPlayerModule: Module {
guard let source = source,
let urlString = source["url"] as? String,
let videoURL = URL(string: urlString) else { return }
-
+
+ // Parse cache config if provided
+ let cacheConfig = source["cacheConfig"] as? [String: Any]
+
let config = VideoLoadConfig(
url: videoURL,
headers: source["headers"] as? [String: String],
@@ -37,9 +40,13 @@ public class MpvPlayerModule: Module {
startPosition: source["startPosition"] as? Double,
autoplay: (source["autoplay"] as? Bool) ?? true,
initialSubtitleId: source["initialSubtitleId"] as? Int,
- initialAudioId: source["initialAudioId"] as? Int
+ initialAudioId: source["initialAudioId"] as? Int,
+ cacheEnabled: cacheConfig?["enabled"] as? String,
+ cacheSeconds: cacheConfig?["cacheSeconds"] as? Int,
+ demuxerMaxBytes: cacheConfig?["maxBytes"] as? Int,
+ demuxerMaxBackBytes: cacheConfig?["maxBackBytes"] as? Int
)
-
+
view.loadVideo(config: config)
}
@@ -150,7 +157,19 @@ public class MpvPlayerModule: Module {
AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in
view.setSubtitleFontSize(size)
}
-
+
+ AsyncFunction("setSubtitleBackgroundColor") { (view: MpvPlayerView, color: String) in
+ view.setSubtitleBackgroundColor(color)
+ }
+
+ AsyncFunction("setSubtitleBorderStyle") { (view: MpvPlayerView, style: String) in
+ view.setSubtitleBorderStyle(style)
+ }
+
+ AsyncFunction("setSubtitleAssOverride") { (view: MpvPlayerView, mode: String) in
+ view.setSubtitleAssOverride(mode)
+ }
+
// Audio track functions
AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]] in
return view.getAudioTracks()
diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift
index 69c6d272..5fdf5a97 100644
--- a/modules/mpv-player/ios/MpvPlayerView.swift
+++ b/modules/mpv-player/ios/MpvPlayerView.swift
@@ -15,7 +15,12 @@ struct VideoLoadConfig {
var initialSubtitleId: Int?
/// MPV audio track ID to select on start (1-based, nil to use default)
var initialAudioId: Int?
-
+ /// Cache/buffer settings
+ var cacheEnabled: String? // "auto", "yes", or "no"
+ var cacheSeconds: Int? // Seconds of video to buffer
+ var demuxerMaxBytes: Int? // Max cache size in MB
+ var demuxerMaxBackBytes: Int? // Max backward cache size in MB
+
init(
url: URL,
headers: [String: String]? = nil,
@@ -23,7 +28,11 @@ struct VideoLoadConfig {
startPosition: Double? = nil,
autoplay: Bool = true,
initialSubtitleId: Int? = nil,
- initialAudioId: Int? = nil
+ initialAudioId: Int? = nil,
+ cacheEnabled: String? = nil,
+ cacheSeconds: Int? = nil,
+ demuxerMaxBytes: Int? = nil,
+ demuxerMaxBackBytes: Int? = nil
) {
self.url = url
self.headers = headers
@@ -32,6 +41,10 @@ struct VideoLoadConfig {
self.autoplay = autoplay
self.initialSubtitleId = initialSubtitleId
self.initialAudioId = initialAudioId
+ self.cacheEnabled = cacheEnabled
+ self.cacheSeconds = cacheSeconds
+ self.demuxerMaxBytes = demuxerMaxBytes
+ self.demuxerMaxBackBytes = demuxerMaxBackBytes
}
}
@@ -54,6 +67,7 @@ class MpvPlayerView: ExpoView {
private var cachedDuration: Double = 0
private var intendedPlayState: Bool = false
private var _isZoomedToFill: Bool = false
+ private var appStateObserver: NSObjectProtocol?
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
@@ -101,6 +115,17 @@ class MpvPlayerView: ExpoView {
} catch {
onError(["error": "Failed to start renderer: \(error.localizedDescription)"])
}
+
+ // Pause playback when app enters background on tvOS
+ #if os(tvOS)
+ appStateObserver = NotificationCenter.default.addObserver(
+ forName: UIApplication.didEnterBackgroundNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.pause()
+ }
+ #endif
}
override func layoutSubviews() {
@@ -151,13 +176,17 @@ class MpvPlayerView: ExpoView {
startPosition: config.startPosition,
externalSubtitles: config.externalSubtitles,
initialSubtitleId: config.initialSubtitleId,
- initialAudioId: config.initialAudioId
+ initialAudioId: config.initialAudioId,
+ cacheEnabled: config.cacheEnabled,
+ cacheSeconds: config.cacheSeconds,
+ demuxerMaxBytes: config.demuxerMaxBytes,
+ demuxerMaxBackBytes: config.demuxerMaxBackBytes
)
-
+
if config.autoplay {
play()
}
-
+
onLoad(["url": config.url.absoluteString])
}
@@ -290,6 +319,18 @@ class MpvPlayerView: ExpoView {
renderer?.setSubtitleFontSize(size)
}
+ func setSubtitleBackgroundColor(_ color: String) {
+ renderer?.setSubtitleBackgroundColor(color)
+ }
+
+ func setSubtitleBorderStyle(_ style: String) {
+ renderer?.setSubtitleBorderStyle(style)
+ }
+
+ func setSubtitleAssOverride(_ mode: String) {
+ renderer?.setSubtitleAssOverride(mode)
+ }
+
// MARK: - Video Scaling
func setZoomedToFill(_ zoomed: Bool) {
@@ -308,6 +349,9 @@ class MpvPlayerView: ExpoView {
}
deinit {
+ if let observer = appStateObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
#if os(tvOS)
resetDisplayCriteria()
#endif
diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts
index 23f86093..552cbefa 100644
--- a/modules/mpv-player/src/MpvPlayer.types.ts
+++ b/modules/mpv-player/src/MpvPlayer.types.ts
@@ -43,6 +43,17 @@ export type VideoSource = {
initialSubtitleId?: number;
/** MPV audio track ID to select on start (1-based) */
initialAudioId?: number;
+ /** MPV cache/buffer configuration */
+ cacheConfig?: {
+ /** Whether caching is enabled: "auto" (default), "yes", or "no" */
+ enabled?: "auto" | "yes" | "no";
+ /** Seconds of video to buffer (default: 10, range: 5-120) */
+ cacheSeconds?: number;
+ /** Maximum cache size in MB (default: 150, range: 50-500) */
+ maxBytes?: number;
+ /** Maximum backward cache size in MB (default: 50, range: 25-200) */
+ maxBackBytes?: number;
+ };
};
export type MpvPlayerViewProps = {
@@ -84,6 +95,11 @@ export interface MpvPlayerViewRef {
setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise;
setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise;
setSubtitleFontSize: (size: number) => Promise;
+ setSubtitleBackgroundColor: (color: string) => Promise;
+ setSubtitleBorderStyle: (
+ style: "outline-and-shadow" | "background-box",
+ ) => Promise;
+ setSubtitleAssOverride: (mode: "no" | "force") => Promise;
// Audio controls
getAudioTracks: () => Promise;
setAudioTrack: (trackId: number) => Promise;
diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx
index ad3fcdfa..cec13b0f 100644
--- a/modules/mpv-player/src/MpvPlayerView.tsx
+++ b/modules/mpv-player/src/MpvPlayerView.tsx
@@ -84,6 +84,17 @@ export default React.forwardRef(
setSubtitleFontSize: async (size: number) => {
await nativeRef.current?.setSubtitleFontSize(size);
},
+ setSubtitleBackgroundColor: async (color: string) => {
+ await nativeRef.current?.setSubtitleBackgroundColor(color);
+ },
+ setSubtitleBorderStyle: async (
+ style: "outline-and-shadow" | "background-box",
+ ) => {
+ await nativeRef.current?.setSubtitleBorderStyle(style);
+ },
+ setSubtitleAssOverride: async (mode: "no" | "force") => {
+ await nativeRef.current?.setSubtitleAssOverride(mode);
+ },
// Audio controls
getAudioTracks: async () => {
return await nativeRef.current?.getAudioTracks();
diff --git a/modules/tv-user-profile/expo-module.config.json b/modules/tv-user-profile/expo-module.config.json
new file mode 100644
index 00000000..6b34d793
--- /dev/null
+++ b/modules/tv-user-profile/expo-module.config.json
@@ -0,0 +1,8 @@
+{
+ "name": "tv-user-profile",
+ "version": "1.0.0",
+ "platforms": ["apple"],
+ "apple": {
+ "modules": ["TvUserProfileModule"]
+ }
+}
diff --git a/modules/tv-user-profile/index.ts b/modules/tv-user-profile/index.ts
new file mode 100644
index 00000000..b6789782
--- /dev/null
+++ b/modules/tv-user-profile/index.ts
@@ -0,0 +1,84 @@
+import type { EventSubscription } from "expo-modules-core";
+import { Platform, requireNativeModule } from "expo-modules-core";
+
+interface TvUserProfileModuleEvents {
+ onProfileChange: (event: { profileId: string | null }) => void;
+}
+
+interface TvUserProfileModuleType {
+ getCurrentProfileId(): string | null;
+ isProfileSwitchingSupported(): boolean;
+ addListener(
+ eventName: K,
+ listener: TvUserProfileModuleEvents[K],
+ ): EventSubscription;
+}
+
+// Only load the native module on Apple platforms
+const TvUserProfileModule: TvUserProfileModuleType | null =
+ Platform.OS === "ios"
+ ? requireNativeModule("TvUserProfile")
+ : null;
+
+/**
+ * Get the current tvOS profile identifier.
+ * Returns null on non-tvOS platforms or if no profile is active.
+ */
+export function getCurrentProfileId(): string | null {
+ if (!TvUserProfileModule) {
+ return null;
+ }
+
+ try {
+ return TvUserProfileModule.getCurrentProfileId() ?? null;
+ } catch (error) {
+ console.error("[TvUserProfile] Error getting profile ID:", error);
+ return null;
+ }
+}
+
+/**
+ * Check if tvOS profile switching is supported on this device.
+ * Returns true only on tvOS.
+ */
+export function isProfileSwitchingSupported(): boolean {
+ if (!TvUserProfileModule) {
+ return false;
+ }
+
+ try {
+ return TvUserProfileModule.isProfileSwitchingSupported();
+ } catch (error) {
+ console.error("[TvUserProfile] Error checking profile support:", error);
+ return false;
+ }
+}
+
+/**
+ * Subscribe to profile change events.
+ * The callback receives the new profile ID (or null if no profile).
+ * Returns an unsubscribe function.
+ */
+export function addProfileChangeListener(
+ callback: (profileId: string | null) => void,
+): () => void {
+ if (!TvUserProfileModule) {
+ // Return no-op unsubscribe on unsupported platforms
+ return () => {};
+ }
+
+ const subscription = TvUserProfileModule.addListener(
+ "onProfileChange",
+ (event) => {
+ callback(event.profileId);
+ },
+ );
+
+ return () => subscription.remove();
+}
+
+export default {
+ getCurrentProfileId,
+ isProfileSwitchingSupported,
+ addProfileChangeListener,
+};
diff --git a/modules/tv-user-profile/ios/TvUserProfile.podspec b/modules/tv-user-profile/ios/TvUserProfile.podspec
new file mode 100644
index 00000000..648af143
--- /dev/null
+++ b/modules/tv-user-profile/ios/TvUserProfile.podspec
@@ -0,0 +1,23 @@
+Pod::Spec.new do |s|
+ s.name = 'TvUserProfile'
+ s.version = '1.0.0'
+ s.summary = 'tvOS User Profile Management for Expo'
+ s.description = 'Native tvOS module to get current user profile and listen for profile changes using TVUserManager'
+ s.author = ''
+ s.homepage = 'https://docs.expo.dev/modules/'
+ s.platforms = { :ios => '15.6', :tvos => '15.0' }
+ s.source = { git: '' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ # TVServices framework is only available on tvOS
+ s.tvos.frameworks = 'TVServices'
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
+end
diff --git a/modules/tv-user-profile/ios/TvUserProfileModule.swift b/modules/tv-user-profile/ios/TvUserProfileModule.swift
new file mode 100644
index 00000000..8a3d2a71
--- /dev/null
+++ b/modules/tv-user-profile/ios/TvUserProfileModule.swift
@@ -0,0 +1,82 @@
+import ExpoModulesCore
+#if os(tvOS)
+import TVServices
+#endif
+
+public class TvUserProfileModule: Module {
+ #if os(tvOS)
+ private let userManager = TVUserManager()
+ private var profileObservation: NSKeyValueObservation?
+ #endif
+
+ public func definition() -> ModuleDefinition {
+ Name("TvUserProfile")
+
+ // Define event that can be sent to JavaScript
+ Events("onProfileChange")
+
+ // Get current tvOS profile identifier
+ Function("getCurrentProfileId") { () -> String? in
+ #if os(tvOS)
+ let identifier = self.userManager.currentUserIdentifier
+ print("[TvUserProfile] Current profile ID: \(identifier ?? "nil")")
+ return identifier
+ #else
+ return nil
+ #endif
+ }
+
+ // Check if running on tvOS with profile support
+ Function("isProfileSwitchingSupported") { () -> Bool in
+ #if os(tvOS)
+ return true
+ #else
+ return false
+ #endif
+ }
+
+ OnCreate {
+ #if os(tvOS)
+ self.setupProfileObserver()
+ #endif
+ }
+
+ OnDestroy {
+ #if os(tvOS)
+ self.profileObservation?.invalidate()
+ self.profileObservation = nil
+ #endif
+ }
+ }
+
+ #if os(tvOS)
+ private func setupProfileObserver() {
+ // Debug: Print all available info about TVUserManager
+ print("[TvUserProfile] TVUserManager created")
+ print("[TvUserProfile] currentUserIdentifier: \(userManager.currentUserIdentifier ?? "nil")")
+ if #available(tvOS 16.0, *) {
+ print("[TvUserProfile] shouldStorePreferencesForCurrentUser: \(userManager.shouldStorePreferencesForCurrentUser)")
+ }
+
+ // Set up KVO observation on currentUserIdentifier
+ profileObservation = userManager.observe(\.currentUserIdentifier, options: [.new, .old, .initial]) { [weak self] manager, change in
+ guard let self = self else { return }
+
+ let newProfileId = change.newValue ?? nil
+ let oldProfileId = change.oldValue ?? nil
+
+ print("[TvUserProfile] KVO fired - old: \(oldProfileId ?? "nil"), new: \(newProfileId ?? "nil")")
+
+ // Only send event if the profile actually changed
+ if newProfileId != oldProfileId {
+ print("[TvUserProfile] Profile changed from \(oldProfileId ?? "nil") to \(newProfileId ?? "nil")")
+ self.sendEvent("onProfileChange", [
+ "profileId": newProfileId as Any
+ ])
+ }
+ }
+
+ print("[TvUserProfile] Profile observer set up successfully")
+ }
+ #endif
+}
diff --git a/package.json b/package.json
index 3252ff2e..f231e2bf 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,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",
@@ -98,7 +99,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",
diff --git a/plugins/withTVUserManagement.js b/plugins/withTVUserManagement.js
new file mode 100644
index 00000000..0cb2f8e8
--- /dev/null
+++ b/plugins/withTVUserManagement.js
@@ -0,0 +1,20 @@
+const { withEntitlementsPlist } = require("expo/config-plugins");
+
+/**
+ * Expo config plugin to add User Management entitlement for tvOS profile linking
+ */
+const withTVUserManagement = (config) => {
+ return withEntitlementsPlist(config, (config) => {
+ // Only add for tvOS builds (check if building for TV)
+ // The entitlement is needed for TVUserManager.currentUserIdentifier to work
+ config.modResults["com.apple.developer.user-management"] = [
+ "runs-as-current-user",
+ ];
+
+ console.log("[withTVUserManagement] Added user-management entitlement");
+
+ return config;
+ });
+};
+
+module.exports = withTVUserManagement;
diff --git a/plugins/withTVXcodeEnv.js b/plugins/withTVXcodeEnv.js
index ccdbc842..86f36755 100644
--- a/plugins/withTVXcodeEnv.js
+++ b/plugins/withTVXcodeEnv.js
@@ -1,13 +1,16 @@
const { withDangerousMod } = require("@expo/config-plugins");
+const { execSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
/**
- * Expo config plugin that adds EXPO_TV=1 to .xcode.env.local for TV builds.
+ * Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
*
* This ensures that when building directly from Xcode (without using `bun run ios:tv`),
* Metro bundler knows it's a TV build and properly excludes unsupported modules
* like react-native-track-player.
+ *
+ * It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
*/
const withTVXcodeEnv = (config) => {
// Only apply for TV builds
@@ -27,21 +30,88 @@ const withTVXcodeEnv = (config) => {
content = fs.readFileSync(xcodeEnvLocalPath, "utf-8");
}
+ let modified = false;
+
+ // Add NODE_BINARY if not already present (needed for nvm users)
+ if (!content.includes("export NODE_BINARY=")) {
+ const nodePath = getNodeBinaryPath();
+ if (nodePath) {
+ if (content.length > 0 && !content.endsWith("\n")) {
+ content += "\n";
+ }
+ content += `export NODE_BINARY=${nodePath}\n`;
+ modified = true;
+ console.log(
+ `[withTVXcodeEnv] Added NODE_BINARY=${nodePath} to .xcode.env.local`,
+ );
+ }
+ }
+
// Add EXPO_TV=1 if not already present
const expoTvExport = "export EXPO_TV=1";
if (!content.includes(expoTvExport)) {
- // Ensure we have a newline at the end before adding
if (content.length > 0 && !content.endsWith("\n")) {
content += "\n";
}
content += `${expoTvExport}\n`;
- fs.writeFileSync(xcodeEnvLocalPath, content);
+ modified = true;
console.log("[withTVXcodeEnv] Added EXPO_TV=1 to .xcode.env.local");
}
+ if (modified) {
+ fs.writeFileSync(xcodeEnvLocalPath, content);
+ }
+
return config;
},
]);
};
+/**
+ * Get the actual node binary path, handling nvm installations.
+ */
+function getNodeBinaryPath() {
+ try {
+ // First try to get node path directly (works for non-nvm installs)
+ const directPath = execSync("which node 2>/dev/null", {
+ encoding: "utf-8",
+ }).trim();
+ if (directPath && fs.existsSync(directPath)) {
+ return directPath;
+ }
+ } catch {
+ // Ignore errors
+ }
+
+ try {
+ // For nvm users, source nvm and get the path
+ const nvmPath = execSync(
+ 'bash -c "source ~/.nvm/nvm.sh 2>/dev/null && which node"',
+ { encoding: "utf-8" },
+ ).trim();
+ if (nvmPath && fs.existsSync(nvmPath)) {
+ return nvmPath;
+ }
+ } catch {
+ // Ignore errors
+ }
+
+ // Fallback: look for node in common nvm location
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
+ if (homeDir) {
+ const nvmVersionsDir = path.join(homeDir, ".nvm", "versions", "node");
+ if (fs.existsSync(nvmVersionsDir)) {
+ const versions = fs.readdirSync(nvmVersionsDir).sort().reverse();
+ for (const version of versions) {
+ const nodeBin = path.join(nvmVersionsDir, version, "bin", "node");
+ if (fs.existsSync(nodeBin)) {
+ return nodeBin;
+ }
+ }
+ }
+ }
+
+ return null;
+}
+
module.exports = withTVXcodeEnv;
diff --git a/providers/InactivityProvider.tsx b/providers/InactivityProvider.tsx
new file mode 100644
index 00000000..2c47ada6
--- /dev/null
+++ b/providers/InactivityProvider.tsx
@@ -0,0 +1,221 @@
+import type React from "react";
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+} from "react";
+import { AppState, type AppStateStatus, Platform } from "react-native";
+import { useJellyfin } from "@/providers/JellyfinProvider";
+import { InactivityTimeout, useSettings } from "@/utils/atoms/settings";
+import { storage } from "@/utils/mmkv";
+
+const INACTIVITY_LAST_ACTIVITY_KEY = "INACTIVITY_LAST_ACTIVITY";
+
+interface InactivityContextValue {
+ resetInactivityTimer: () => void;
+ pauseInactivityTimer: () => void;
+ resumeInactivityTimer: () => void;
+}
+
+const InactivityContext = createContext(
+ undefined,
+);
+
+/**
+ * TV-only provider that tracks user inactivity and auto-logs out
+ * when the configured timeout is exceeded.
+ *
+ * Features:
+ * - Tracks last activity timestamp (persisted to MMKV)
+ * - Resets timer on any focus change (via resetInactivityTimer)
+ * - Pauses timer during video playback (via pauseInactivityTimer/resumeInactivityTimer)
+ * - Handles app backgrounding: logs out immediately if timeout exceeded while away
+ * - No-op on mobile platforms
+ */
+export const InactivityProvider: React.FC<{ children: ReactNode }> = ({
+ children,
+}) => {
+ const { settings } = useSettings();
+ const { logout } = useJellyfin();
+ const timerRef = useRef(null);
+ const appStateRef = useRef(AppState.currentState);
+ const isPausedRef = useRef(false);
+
+ const timeoutMs = settings.inactivityTimeout ?? InactivityTimeout.Disabled;
+ const isEnabled = Platform.isTV && timeoutMs > 0;
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ const updateLastActivity = useCallback(() => {
+ if (!isEnabled) return;
+ storage.set(INACTIVITY_LAST_ACTIVITY_KEY, Date.now());
+ }, [isEnabled]);
+
+ const getLastActivity = useCallback((): number => {
+ return storage.getNumber(INACTIVITY_LAST_ACTIVITY_KEY) ?? Date.now();
+ }, []);
+
+ const startTimer = useCallback(
+ (remainingMs?: number) => {
+ if (!isEnabled || isPausedRef.current) return;
+
+ clearTimer();
+
+ const delay = remainingMs ?? timeoutMs;
+ timerRef.current = setTimeout(() => {
+ logout();
+ storage.remove(INACTIVITY_LAST_ACTIVITY_KEY);
+ }, delay);
+ },
+ [isEnabled, timeoutMs, clearTimer, logout],
+ );
+
+ const resetInactivityTimer = useCallback(() => {
+ if (!isEnabled || isPausedRef.current) return;
+
+ updateLastActivity();
+ startTimer();
+ }, [isEnabled, updateLastActivity, startTimer]);
+
+ const pauseInactivityTimer = useCallback(() => {
+ if (!isEnabled) return;
+
+ isPausedRef.current = true;
+ clearTimer();
+ // Update last activity so when we resume, we start fresh
+ updateLastActivity();
+ }, [isEnabled, clearTimer, updateLastActivity]);
+
+ const resumeInactivityTimer = useCallback(() => {
+ if (!isEnabled) return;
+
+ isPausedRef.current = false;
+ updateLastActivity();
+ startTimer();
+ }, [isEnabled, updateLastActivity, startTimer]);
+
+ // Handle app state changes (background/foreground)
+ useEffect(() => {
+ if (!isEnabled) return;
+
+ const handleAppStateChange = (nextAppState: AppStateStatus) => {
+ const wasBackground =
+ appStateRef.current === "background" ||
+ appStateRef.current === "inactive";
+ const isNowActive = nextAppState === "active";
+
+ if (wasBackground && isNowActive) {
+ // App returned to foreground
+ // If paused (e.g., video playing), don't check timeout
+ if (isPausedRef.current) {
+ appStateRef.current = nextAppState;
+ return;
+ }
+
+ // Check if timeout exceeded
+ const lastActivity = getLastActivity();
+ const elapsed = Date.now() - lastActivity;
+
+ if (elapsed >= timeoutMs) {
+ // Timeout exceeded while backgrounded - logout immediately
+ logout();
+ storage.remove(INACTIVITY_LAST_ACTIVITY_KEY);
+ } else {
+ // Restart timer with remaining time
+ const remainingMs = timeoutMs - elapsed;
+ startTimer(remainingMs);
+ }
+ } else if (nextAppState === "background" || nextAppState === "inactive") {
+ // App going to background - clear timer (time continues via timestamp)
+ clearTimer();
+ }
+
+ appStateRef.current = nextAppState;
+ };
+
+ const subscription = AppState.addEventListener(
+ "change",
+ handleAppStateChange,
+ );
+
+ return () => {
+ subscription.remove();
+ };
+ }, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]);
+
+ // Initialize timer when enabled or timeout changes
+ useEffect(() => {
+ if (!isEnabled) {
+ clearTimer();
+ return;
+ }
+
+ // Don't start timer if paused
+ if (isPausedRef.current) return;
+
+ // Check if we should logout based on last activity
+ const lastActivity = getLastActivity();
+ const elapsed = Date.now() - lastActivity;
+
+ if (elapsed >= timeoutMs) {
+ // Already timed out - logout
+ logout();
+ storage.remove(INACTIVITY_LAST_ACTIVITY_KEY);
+ } else {
+ // Start timer with remaining time
+ const remainingMs = timeoutMs - elapsed;
+ startTimer(remainingMs);
+ }
+
+ return () => {
+ clearTimer();
+ };
+ }, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]);
+
+ // Reset activity on initial mount when enabled
+ useEffect(() => {
+ if (isEnabled && !isPausedRef.current) {
+ updateLastActivity();
+ startTimer();
+ }
+ }, []);
+
+ const contextValue: InactivityContextValue = {
+ resetInactivityTimer,
+ pauseInactivityTimer,
+ resumeInactivityTimer,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * Hook to access the inactivity timer controls.
+ * Returns no-op functions if not within the provider (safe on mobile).
+ */
+export const useInactivity = (): InactivityContextValue => {
+ const context = useContext(InactivityContext);
+
+ // Return no-ops if not within provider (e.g., on mobile)
+ if (!context) {
+ return {
+ resetInactivityTimer: () => {},
+ pauseInactivityTimer: () => {},
+ resumeInactivityTimer: () => {},
+ };
+ }
+
+ return context;
+};
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index 0bce8439..ebdaaca1 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -2,7 +2,7 @@ import "@/augmentations";
import { type Api, Jellyfin } from "@jellyfin/sdk";
import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios, { AxiosError } from "axios";
import { useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
@@ -29,6 +29,7 @@ import { writeErrorLog, writeInfoLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import {
type AccountSecurityType,
+ addAccountToServer,
addServerToList,
deleteAccountCredential,
getAccountCredential,
@@ -65,6 +66,7 @@ interface JellyfinContextValue {
) => Promise;
logout: () => Promise;
initiateQuickConnect: () => Promise;
+ stopQuickConnectPolling: () => void;
loginWithSavedCredential: (
serverUrl: string,
userId: string,
@@ -114,6 +116,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [secret, setSecret] = useState(null);
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
+ const queryClient = useQueryClient();
const headers = useMemo(() => {
if (!deviceId) return {};
@@ -146,6 +149,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
}, [api, deviceId, headers]);
+ const stopQuickConnectPolling = useCallback(() => {
+ setIsPolling(false);
+ setSecret(null);
+ }, []);
+
const pollQuickConnect = useCallback(async () => {
if (!api || !secret || !jellyfin) return;
@@ -178,10 +186,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
return false;
} catch (error) {
- if (error instanceof AxiosError && error.response?.status === 400) {
- setIsPolling(false);
- setSecret(null);
- throw new Error("The code has expired. Please try again.");
+ if (error instanceof AxiosError) {
+ if (error.response?.status === 400 || error.response?.status === 404) {
+ setIsPolling(false);
+ setSecret(null);
+ if (error.response?.status === 400) {
+ throw new Error("The code has expired. Please try again.");
+ }
+ return false;
+ }
}
console.error("Error polling Quick Connect:", error);
throw error;
@@ -286,6 +299,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
savedAt: Date.now(),
securityType,
pinHash,
+ primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
});
}
@@ -338,7 +352,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const logoutMutation = useMutation({
mutationFn: async () => {
- await api
+ // Fire-and-forget: don't block logout on server cleanup
+ api
?.delete(`/Streamyfin/device/${deviceId}`)
.then((_r) => writeInfoLog("Deleted expo push token for device"))
.catch((_e) =>
@@ -350,6 +365,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setApi(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
+
+ // Clear React Query cache to prevent data from previous account lingering
+ queryClient.clear();
+ storage.remove("REACT_QUERY_OFFLINE_CACHE");
+
// Note: We keep saved credentials for quick switching back
},
onError: (error) => {
@@ -382,6 +402,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
try {
const response = await getUserApi(apiInstance).getCurrentUser();
+ // Clear React Query cache to prevent data from previous account lingering
+ queryClient.clear();
+ storage.remove("REACT_QUERY_OFFLINE_CACHE");
+
// Token is valid, update state
setApi(apiInstance);
setUser(response.data);
@@ -389,17 +413,47 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("token", credential.token);
storage.set("user", JSON.stringify(response.data));
+ // Update account info (in case user changed their avatar)
+ if (response.data.PrimaryImageTag !== credential.primaryImageTag) {
+ addAccountToServer(serverUrl, credential.serverName, {
+ userId: credential.userId,
+ username: credential.username,
+ securityType: credential.securityType,
+ savedAt: credential.savedAt,
+ primaryImageTag: response.data.PrimaryImageTag ?? undefined,
+ });
+ }
+
// Refresh plugin settings
await refreshStreamyfinPluginSettings();
} catch (error) {
- // Token is invalid/expired - remove it
- if (
- axios.isAxiosError(error) &&
- (error.response?.status === 401 || error.response?.status === 403)
- ) {
- await deleteAccountCredential(serverUrl, userId);
- throw new Error(t("server.session_expired"));
+ // Check for axios error
+ if (axios.isAxiosError(error)) {
+ // Token is invalid/expired - remove it
+ if (
+ error.response?.status === 401 ||
+ error.response?.status === 403
+ ) {
+ await deleteAccountCredential(serverUrl, userId);
+ throw new Error(t("server.session_expired"));
+ }
+
+ // Network error - server not reachable (no response means server didn't respond)
+ if (!error.response) {
+ throw new Error(t("home.server_unreachable"));
+ }
}
+
+ // Check for network error by message pattern (fallback detection)
+ if (
+ error instanceof Error &&
+ (error.message.toLowerCase().includes("network") ||
+ error.message.toLowerCase().includes("econnrefused") ||
+ error.message.toLowerCase().includes("timeout"))
+ ) {
+ throw new Error(t("home.server_unreachable"));
+ }
+
throw error;
}
},
@@ -430,17 +484,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const auth = await apiInstance.authenticateUserByName(username, password);
if (auth.data.AccessToken && auth.data.User) {
+ // Clear React Query cache to prevent data from previous account lingering
+ queryClient.clear();
+ storage.remove("REACT_QUERY_OFFLINE_CACHE");
+
setUser(auth.data.User);
storage.set("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken));
storage.set("serverUrl", serverUrl);
storage.set("token", auth.data.AccessToken);
- // Update the saved credential with new token
+ // Update the saved credential with new token and image tag
await updateAccountToken(
serverUrl,
auth.data.User.Id || "",
auth.data.AccessToken,
+ auth.data.User.PrimaryImageTag ?? undefined,
);
// Refresh plugin settings
@@ -527,6 +586,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
+ primaryImageTag: response.data.PrimaryImageTag ?? undefined,
+ });
+ } else if (
+ response.data.PrimaryImageTag !==
+ existingCredential.primaryImageTag
+ ) {
+ // Update image tag if it has changed
+ addAccountToServer(serverUrl, existingCredential.serverName, {
+ userId: existingCredential.userId,
+ username: existingCredential.username,
+ securityType: existingCredential.securityType,
+ savedAt: existingCredential.savedAt,
+ primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
@@ -549,6 +621,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
loginMutation.mutateAsync({ username, password, serverName, options }),
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
+ stopQuickConnectPolling,
loginWithSavedCredential: (serverUrl, userId) =>
loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
loginWithPassword: (serverUrl, username, password) =>
diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx
index e3718a33..fe1d39f3 100644
--- a/providers/PlaySettingsProvider.tsx
+++ b/providers/PlaySettingsProvider.tsx
@@ -9,7 +9,7 @@ import { Platform } from "react-native";
import type { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { generateDeviceProfile } from "@/utils/profiles/native";
+import { generateDeviceProfile } from "../utils/profiles/native";
import { apiAtom, userAtom } from "./JellyfinProvider";
export type PlaybackType = {
diff --git a/providers/ServerUrlProvider.tsx b/providers/ServerUrlProvider.tsx
index 17ce1773..f73eb907 100644
--- a/providers/ServerUrlProvider.tsx
+++ b/providers/ServerUrlProvider.tsx
@@ -34,13 +34,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
const { switchServerUrl } = useJellyfin();
const { ssid, permissionStatus } = useWifiSSID();
- console.log(
- "[ServerUrlProvider] ssid:",
- ssid,
- "permissionStatus:",
- permissionStatus,
- );
-
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
const [effectiveServerUrl, setEffectiveServerUrl] = useState(
null,
@@ -76,13 +69,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
const targetUrl = shouldUseLocal ? config!.localUrl : remoteUrl;
- console.log("[ServerUrlProvider] evaluateAndSwitchUrl:", {
- ssid,
- shouldUseLocal,
- targetUrl,
- config,
- });
-
switchServerUrl(targetUrl);
setIsUsingLocalUrl(shouldUseLocal);
setEffectiveServerUrl(targetUrl);
@@ -90,7 +76,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
// Manual refresh function for when config changes
const refreshUrlState = useCallback(() => {
- console.log("[ServerUrlProvider] refreshUrlState called");
evaluateAndSwitchUrl();
}, [evaluateAndSwitchUrl]);
diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx
index 78d3c3c8..bb9d1d1f 100644
--- a/providers/WebSocketProvider.tsx
+++ b/providers/WebSocketProvider.tsx
@@ -66,7 +66,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const reconnectDelay = 10000;
newWebSocket.onopen = () => {
- console.log("WebSocket connection opened");
setIsConnected(true);
reconnectAttemptsRef.current = 0;
keepAliveInterval = setInterval(() => {
diff --git a/translations/en.json b/translations/en.json
index 2a629267..e5352295 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -4,6 +4,9 @@
"error_title": "Error",
"login_title": "Log In",
"login_to_title": "Log in to",
+ "select_user": "Select a user to log in",
+ "add_user_to_login": "Add a user to log in",
+ "add_user": "Add User",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log In",
@@ -44,7 +47,11 @@
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
- "remove_server_description": "This will remove {{server}} and all saved accounts from your list."
+ "remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
+ "select_your_server": "Select Your Server",
+ "add_server_to_get_started": "Add a server to get started",
+ "add_server": "Add Server",
+ "change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -112,6 +119,12 @@
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
+ "switch_user": {
+ "title": "Switch User",
+ "account": "Account",
+ "switch_user": "Switch User on This Server",
+ "current": "current"
+ },
"categories": {
"title": "Categories"
},
@@ -125,7 +138,15 @@
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
- "show_home_backdrop": "Dynamic Home Backdrop"
+ "show_home_backdrop": "Dynamic Home Backdrop",
+ "show_hero_carousel": "Hero Carousel",
+ "show_series_poster_on_episode": "Show Series Poster on Episodes",
+ "theme_music": "Theme Music",
+ "display_size": "Display Size",
+ "display_size_small": "Small",
+ "display_size_default": "Default",
+ "display_size_large": "Large",
+ "display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -178,6 +199,16 @@
"rewind_length": "Rewind Length",
"seconds_unit": "s"
},
+ "buffer": {
+ "title": "Buffer Settings",
+ "cache_mode": "Cache Mode",
+ "cache_auto": "Auto",
+ "cache_yes": "Enabled",
+ "cache_no": "Disabled",
+ "buffer_duration": "Buffer Duration",
+ "max_cache_size": "Max Cache Size",
+ "max_backward_cache": "Max Backward Cache"
+ },
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -450,6 +481,21 @@
"error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
+ },
+ "security": {
+ "title": "Security",
+ "inactivity_timeout": {
+ "title": "Inactivity Timeout",
+ "description": "Auto logout after inactivity",
+ "disabled": "Disabled",
+ "1_minute": "1 minute",
+ "5_minutes": "5 minutes",
+ "15_minutes": "15 minutes",
+ "30_minutes": "30 minutes",
+ "1_hour": "1 hour",
+ "4_hours": "4 hours",
+ "24_hours": "24 hours"
+ }
}
},
"sessions": {
@@ -512,6 +558,7 @@
}
},
"common": {
+ "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
@@ -521,6 +568,7 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
+ "stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
@@ -578,6 +626,7 @@
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
+ "playlists": "Playlists",
"items": "Items"
},
"options": {
@@ -617,6 +666,7 @@
"no_links": "No Links"
},
"player": {
+ "live": "LIVE",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
@@ -650,7 +700,13 @@
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
- "settings": "Settings"
+ "settings": "Settings",
+ "skip_intro": "Skip Intro",
+ "skip_credits": "Skip Credits",
+ "stopPlayback": "Stop Playback",
+ "stopPlayingTitle": "Stop playing \"{{title}}\"?",
+ "stopPlayingConfirm": "Are you sure you want to stop playback?",
+ "downloaded": "Downloaded"
},
"item_card": {
"next_up": "Next Up",
@@ -660,6 +716,7 @@
"seasons": "Seasons",
"season": "Season",
"from_this_series": "From This Series",
+ "more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
@@ -698,7 +755,13 @@
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download"
- }
+ },
+ "mark_played": "Mark as Watched",
+ "mark_unplayed": "Mark as Unwatched",
+ "resume_playback": "Resume Playback",
+ "resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
+ "play_from_start": "Play from Start",
+ "continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Next",
@@ -709,7 +772,18 @@
"movies": "Movies",
"sports": "Sports",
"for_kids": "For Kids",
- "news": "News"
+ "news": "News",
+ "page_of": "Page {{current}} of {{total}}",
+ "no_programs": "No programs available",
+ "no_channels": "No channels available",
+ "tabs": {
+ "programs": "Programs",
+ "guide": "Guide",
+ "channels": "Channels",
+ "recordings": "Recordings",
+ "schedule": "Schedule",
+ "series": "Series"
+ }
},
"jellyseerr": {
"confirm": "Confirm",
diff --git a/translations/sv.json b/translations/sv.json
index 483be971..2c95fe77 100644
--- a/translations/sv.json
+++ b/translations/sv.json
@@ -42,7 +42,9 @@
"accounts_count": "{{count}} konton",
"select_account": "Välj konto",
"add_account": "Lägg till konto",
- "remove_account_description": "Detta kommer att ta bort de sparade uppgifterna för {{username}}."
+ "remove_account_description": "Detta kommer att ta bort de sparade uppgifterna för {{username}}.",
+ "remove_server": "Ta bort server",
+ "remove_server_description": "Detta kommer att ta bort {{server}} och alla sparade konton från din lista."
},
"save_account": {
"title": "Spara konto",
@@ -122,7 +124,16 @@
"appearance": {
"title": "Utseende",
"merge_next_up_continue_watching": "Slå ihop Fortsätt titta och nästa avsnitt",
- "hide_remote_session_button": "Dölj fjärrsessionsknapp"
+ "hide_remote_session_button": "Dölj fjärrsessionsknapp",
+ "show_home_backdrop": "Dynamisk hembakgrund",
+ "show_hero_carousel": "Hjältekarusell",
+ "show_series_poster_on_episode": "Visa serieaffisch på avsnitt",
+ "display_size": "Visningsstorlek",
+ "display_size_small": "Liten",
+ "display_size_default": "Standard",
+ "display_size_large": "Stor",
+ "display_size_extra_large": "Extra stor",
+ "theme_music": "Temamusik"
},
"network": {
"title": "Nätverk",
@@ -175,6 +186,16 @@
"rewind_length": "Bakåthoppsintervall",
"seconds_unit": "s"
},
+ "buffer": {
+ "title": "Bufferinställningar",
+ "cache_mode": "Cacheläge",
+ "cache_auto": "Auto",
+ "cache_yes": "Aktiverad",
+ "cache_no": "Inaktiverad",
+ "buffer_duration": "Buffertlängd",
+ "max_cache_size": "Max cachestorlek",
+ "max_backward_cache": "Max bakåtcache"
+ },
"gesture_controls": {
"gesture_controls_title": "Gestkontroller",
"horizontal_swipe_skip": "Horisontell Svepning för att Hoppa Fram/Bak",
@@ -257,7 +278,23 @@
"subtitle_font": "Typsnitt för undertexter",
"ksplayer_title": "KSPlayer-inställningar",
"hardware_decode": "Hårdvaruavkodning",
- "hardware_decode_description": "Använd hårdvaruacceleration för videoavkodning. Inaktivera om du upplever uppspelningsproblem."
+ "hardware_decode_description": "Använd hårdvaruacceleration för videoavkodning. Inaktivera om du upplever uppspelningsproblem.",
+ "opensubtitles_title": "OpenSubtitles",
+ "opensubtitles_hint": "Ange din OpenSubtitles API-nyckel för att aktivera klientbaserad undertextsökning som reserv när din Jellyfin-server inte har en undertextleverantör konfigurerad.",
+ "opensubtitles_api_key": "API-nyckel",
+ "opensubtitles_api_key_placeholder": "Ange API-nyckel...",
+ "opensubtitles_get_key": "Skaffa din gratis API-nyckel på opensubtitles.com/en/consumers",
+ "mpv_subtitle_scale": "Undertextskala",
+ "mpv_subtitle_margin_y": "Vertikal marginal",
+ "mpv_subtitle_align_x": "Horisontell justering",
+ "mpv_subtitle_align_y": "Vertikal justering",
+ "align": {
+ "left": "Vänster",
+ "center": "Mitten",
+ "right": "Höger",
+ "top": "Toppen",
+ "bottom": "Botten"
+ }
},
"vlc_subtitles": {
"title": "VLC undertextsinställningar",
@@ -493,6 +530,7 @@
}
},
"common": {
+ "no_results": "Inga resultat",
"select": "Välj",
"no_trailer_available": "Ingen trailer tillgänglig",
"video": "Video",
@@ -502,13 +540,17 @@
"none": "Ingen",
"track": "Spår",
"cancel": "Avbryt",
+ "stop": "Stoppa",
"delete": "Ta bort",
"ok": "OK",
"remove": "Radera",
"next": "Nästa",
"back": "Tillbaka",
"continue": "Fortsätt",
- "verifying": "Verifierar..."
+ "verifying": "Verifierar...",
+ "login": "Logga in",
+ "refresh": "Uppdatera",
+ "seeAll": "Visa alla"
},
"search": {
"search": "Sök...",
@@ -557,6 +599,7 @@
"movies": "Filmer",
"series": "Serier",
"boxsets": "Box Set",
+ "playlists": "Spellistor",
"items": "Artiklar"
},
"options": {
@@ -596,6 +639,7 @@
"no_links": "Inga Länkar"
},
"player": {
+ "live": "LIVE",
"error": "Fel",
"failed_to_get_stream_url": "Kunde inte hämta stream-URL",
"an_error_occured_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
@@ -614,14 +658,28 @@
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nej",
"downloaded_file_cancel": "Avbryt",
+ "swipe_down_settings": "Svep nedåt för inställningar",
+ "ends_at": "slutar",
+ "search_subtitles": "Sök undertexter",
+ "subtitle_tracks": "Spår",
+ "subtitle_search": "Sök & ladda ner",
"download": "Ladda ner",
+ "subtitle_download_hint": "Nedladdade undertexter sparas i ditt bibliotek",
+ "using_jellyfin_server": "Använder Jellyfin-server",
"language": "Språk",
"results": "Resultat",
"searching": "Söker...",
"search_failed": "Sökningen misslyckades",
"no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern",
"no_subtitles_found": "Inga undertexter hittades",
- "add_opensubtitles_key_hint": "Lägg till OpenSubtitles API-nyckel i inställningar för klientsidesökning som reserv"
+ "add_opensubtitles_key_hint": "Lägg till OpenSubtitles API-nyckel i inställningar för klientsidesökning som reserv",
+ "settings": "Inställningar",
+ "skip_intro": "Hoppa över intro",
+ "skip_credits": "Hoppa över eftertexter",
+ "stopPlayback": "Stoppa uppspelning",
+ "stopPlayingTitle": "Sluta spela \"{{title}}\"?",
+ "stopPlayingConfirm": "Är du säker på att du vill stoppa uppspelningen?",
+ "downloaded": "Nedladdad"
},
"item_card": {
"next_up": "Näst på tur",
@@ -631,8 +689,10 @@
"seasons": "Säsonger",
"season": "Säsong ",
"from_this_series": "Från den här serien",
+ "more_from_this_season": "Från denna säsong",
"view_series": "Visa serien",
"view_season": "Visa säsongen",
+ "select_season": "Välj säsong",
"no_episodes_for_this_season": "Inga avsnitt för den här säsongen",
"overview": "Översikt",
"more_with": "Mer med {{name}}",
@@ -650,7 +710,14 @@
},
"show_more": "Visa Mer",
"show_less": "Visa Mindre",
+ "left": "kvar",
+ "more_info": "Mer info",
+ "director": "Regissör",
+ "cast": "Skådespelare",
+ "technical_details": "Tekniska detaljer",
"appeared_in": "Förekommer I",
+ "movies": "Filmer",
+ "shows": "Serier",
"could_not_load_item": "Kunde Inte Ladda Artikeln",
"none": "Inget",
"download": {
@@ -661,7 +728,13 @@
"download_x_item": "Ladda Ner {{item_count}} Objekt",
"download_unwatched_only": "Endast Osedda",
"download_button": "Ladda ner"
- }
+ },
+ "mark_played": "Markera som sedd",
+ "mark_unplayed": "Markera som osedd",
+ "resume_playback": "Återuppta uppspelning",
+ "resume_playback_description": "Vill du fortsätta där du slutade eller börja om från början?",
+ "play_from_start": "Spela från början",
+ "continue_from": "Fortsätt från {{time}}"
},
"live_tv": {
"next": "Nästa",
@@ -672,7 +745,18 @@
"movies": "Filmer",
"sports": "Sport",
"for_kids": "För barn",
- "news": "Nyheter"
+ "news": "Nyheter",
+ "page_of": "Sida {{current}} av {{total}}",
+ "no_programs": "Inga program tillgängliga",
+ "no_channels": "Inga kanaler tillgängliga",
+ "tabs": {
+ "programs": "Program",
+ "guide": "Guide",
+ "channels": "Kanaler",
+ "recordings": "Inspelningar",
+ "schedule": "Schema",
+ "series": "Serier"
+ }
},
"jellyseerr": {
"confirm": "Bekräfta",
@@ -719,6 +803,10 @@
"unknown_user": "Okänd användare",
"select": "Välj",
"request_all": "Begär alla",
+ "request_seasons": "Begär säsonger",
+ "select_seasons": "Välj säsonger",
+ "request_selected": "Begär valda",
+ "n_selected": "{{count}} valda",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr-servern uppfyller inte minimikrav för version! Vänligen uppdatera till minst 2.0.0",
"jellyseerr_test_failed": "Seerr test misslyckades. Försök igen.",
@@ -738,7 +826,8 @@
"search": "Sök",
"library": "Bibliotek",
"custom_links": "Egna länkar",
- "favorites": "Favoriter"
+ "favorites": "Favoriter",
+ "settings": "Inställningar"
},
"music": {
"title": "Musik",
diff --git a/utils/atoms/downloadedSubtitles.ts b/utils/atoms/downloadedSubtitles.ts
new file mode 100644
index 00000000..69d17a9f
--- /dev/null
+++ b/utils/atoms/downloadedSubtitles.ts
@@ -0,0 +1,162 @@
+/**
+ * Downloaded Subtitles Storage
+ *
+ * Persists metadata about client-side downloaded subtitles (from OpenSubtitles).
+ * Subtitle files are stored in Paths.cache/streamyfin-subtitles/ directory.
+ * Filenames are prefixed with itemId for organization: {itemId}_{filename}
+ *
+ * While files are in cache, metadata is persisted in MMKV so subtitles survive
+ * app restarts (unless cache is manually cleared by the user).
+ *
+ * TV platform only.
+ */
+
+import { storage } from "../mmkv";
+
+// MMKV storage key
+const DOWNLOADED_SUBTITLES_KEY = "downloadedSubtitles.json";
+
+/**
+ * Metadata for a downloaded subtitle file
+ */
+export interface DownloadedSubtitle {
+ /** Unique identifier (uuid) */
+ id: string;
+ /** Jellyfin item ID */
+ itemId: string;
+ /** Local file path in documents directory */
+ filePath: string;
+ /** Display name */
+ name: string;
+ /** 3-letter language code */
+ language: string;
+ /** File format (srt, ass, etc.) */
+ format: string;
+ /** Source provider */
+ source: "opensubtitles";
+ /** Unix timestamp when downloaded */
+ downloadedAt: number;
+}
+
+/**
+ * Storage structure for downloaded subtitles
+ */
+interface DownloadedSubtitlesStorage {
+ /** Map of itemId to array of downloaded subtitles */
+ byItemId: Record;
+}
+
+/**
+ * Load the storage from MMKV
+ */
+function loadStorage(): DownloadedSubtitlesStorage {
+ try {
+ const data = storage.getString(DOWNLOADED_SUBTITLES_KEY);
+ if (data) {
+ return JSON.parse(data) as DownloadedSubtitlesStorage;
+ }
+ } catch {
+ // Ignore parse errors, return empty storage
+ }
+ return { byItemId: {} };
+}
+
+/**
+ * Save the storage to MMKV
+ */
+function saveStorage(data: DownloadedSubtitlesStorage): void {
+ try {
+ storage.set(DOWNLOADED_SUBTITLES_KEY, JSON.stringify(data));
+ } catch (error) {
+ console.error("Failed to save downloaded subtitles:", error);
+ }
+}
+
+/**
+ * Get all downloaded subtitles for a specific Jellyfin item
+ */
+export function getSubtitlesForItem(itemId: string): DownloadedSubtitle[] {
+ const data = loadStorage();
+ return data.byItemId[itemId] ?? [];
+}
+
+/**
+ * Add a downloaded subtitle to storage
+ */
+export function addDownloadedSubtitle(subtitle: DownloadedSubtitle): void {
+ const data = loadStorage();
+
+ // Initialize array for item if it doesn't exist
+ if (!data.byItemId[subtitle.itemId]) {
+ data.byItemId[subtitle.itemId] = [];
+ }
+
+ // Check if subtitle with same id already exists and update it
+ const existingIndex = data.byItemId[subtitle.itemId].findIndex(
+ (s) => s.id === subtitle.id,
+ );
+
+ if (existingIndex !== -1) {
+ // Update existing entry
+ data.byItemId[subtitle.itemId][existingIndex] = subtitle;
+ } else {
+ // Add new entry
+ data.byItemId[subtitle.itemId].push(subtitle);
+ }
+
+ saveStorage(data);
+}
+
+/**
+ * Remove a downloaded subtitle from storage
+ */
+export function removeDownloadedSubtitle(
+ itemId: string,
+ subtitleId: string,
+): void {
+ const data = loadStorage();
+
+ if (data.byItemId[itemId]) {
+ data.byItemId[itemId] = data.byItemId[itemId].filter(
+ (s) => s.id !== subtitleId,
+ );
+
+ // Clean up empty arrays
+ if (data.byItemId[itemId].length === 0) {
+ delete data.byItemId[itemId];
+ }
+
+ saveStorage(data);
+ }
+}
+
+/**
+ * Remove all downloaded subtitles for a specific item
+ */
+export function removeAllSubtitlesForItem(itemId: string): void {
+ const data = loadStorage();
+
+ if (data.byItemId[itemId]) {
+ delete data.byItemId[itemId];
+ saveStorage(data);
+ }
+}
+
+/**
+ * Check if a subtitle file already exists for an item by language
+ */
+export function hasSubtitleForLanguage(
+ itemId: string,
+ language: string,
+): boolean {
+ const subtitles = getSubtitlesForItem(itemId);
+ return subtitles.some((s) => s.language === language);
+}
+
+/**
+ * Get all downloaded subtitles across all items
+ */
+export function getAllDownloadedSubtitles(): DownloadedSubtitle[] {
+ const data = loadStorage();
+ return Object.values(data.byItemId).flat();
+}
diff --git a/utils/atoms/selectedTVServer.ts b/utils/atoms/selectedTVServer.ts
new file mode 100644
index 00000000..c5d25479
--- /dev/null
+++ b/utils/atoms/selectedTVServer.ts
@@ -0,0 +1,60 @@
+import { atom } from "jotai";
+import { storage } from "../mmkv";
+
+const STORAGE_KEY = "selectedTVServer";
+
+export interface SelectedTVServerState {
+ address: string;
+ name?: string;
+}
+
+/**
+ * Load the selected TV server from MMKV storage.
+ */
+function loadSelectedTVServer(): SelectedTVServerState | null {
+ const stored = storage.getString(STORAGE_KEY);
+ if (stored) {
+ try {
+ return JSON.parse(stored) as SelectedTVServerState;
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+/**
+ * Save the selected TV server to MMKV storage.
+ */
+function saveSelectedTVServer(server: SelectedTVServerState | null): void {
+ if (server) {
+ storage.set(STORAGE_KEY, JSON.stringify(server));
+ } else {
+ storage.remove(STORAGE_KEY);
+ }
+}
+
+/**
+ * Base atom holding the selected TV server state.
+ */
+const baseSelectedTVServerAtom = atom(
+ loadSelectedTVServer(),
+);
+
+/**
+ * Derived atom that persists changes to MMKV storage.
+ */
+export const selectedTVServerAtom = atom(
+ (get) => get(baseSelectedTVServerAtom),
+ (_get, set, newValue: SelectedTVServerState | null) => {
+ saveSelectedTVServer(newValue);
+ set(baseSelectedTVServerAtom, newValue);
+ },
+);
+
+/**
+ * Clear the selected TV server (used when changing servers).
+ */
+export function clearSelectedTVServer(): void {
+ storage.remove(STORAGE_KEY);
+}
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index 21ac3577..f7dbf791 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -134,6 +134,14 @@ export enum VideoPlayer {
MPV = 0,
}
+// TV Typography scale presets
+export enum TVTypographyScale {
+ Small = "small",
+ Default = "default",
+ Large = "large",
+ ExtraLarge = "extraLarge",
+}
+
// Audio transcoding mode - controls how surround audio is handled
// This controls server-side transcoding behavior for audio streams.
// MPV decodes via FFmpeg and supports most formats, but mobile devices
@@ -146,6 +154,21 @@ export enum AudioTranscodeMode {
AllowAll = "passthrough", // Direct play all audio formats
}
+// Inactivity timeout for TV - auto logout after period of no activity
+export enum InactivityTimeout {
+ Disabled = 0,
+ OneMinute = 60000,
+ FiveMinutes = 300000,
+ FifteenMinutes = 900000,
+ ThirtyMinutes = 1800000,
+ OneHour = 3600000,
+ FourHours = 14400000,
+ TwentyFourHours = 86400000,
+}
+
+// MPV cache mode - controls how caching is enabled
+export type MpvCacheMode = "auto" | "yes" | "no";
+
export type Settings = {
home?: Home | null;
deviceProfile?: "Expo" | "Native" | "Old";
@@ -191,6 +214,13 @@ export type Settings = {
mpvSubtitleAlignX?: "left" | "center" | "right";
mpvSubtitleAlignY?: "top" | "center" | "bottom";
mpvSubtitleFontSize?: number;
+ mpvSubtitleBackgroundEnabled?: boolean;
+ mpvSubtitleBackgroundOpacity?: number; // 0-100
+ // MPV buffer/cache settings
+ mpvCacheEnabled?: MpvCacheMode;
+ mpvCacheSeconds?: number;
+ mpvDemuxerMaxBytes?: number; // MB
+ mpvDemuxerMaxBackBytes?: number; // MB
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
@@ -198,10 +228,13 @@ export type Settings = {
hideVolumeSlider: boolean;
hideBrightnessSlider: boolean;
usePopularPlugin: boolean;
- showLargeHomeCarousel: boolean;
mergeNextUpAndContinueWatching: boolean;
// TV-specific settings
showHomeBackdrop: boolean;
+ showTVHeroCarousel: boolean;
+ tvTypographyScale: TVTypographyScale;
+ showSeriesPosterOnEpisode: boolean;
+ tvThemeMusicEnabled: boolean;
// Appearance
hideRemoteSessionButton: boolean;
hideWatchlistsTab: boolean;
@@ -215,6 +248,8 @@ export type Settings = {
audioTranscodeMode: AudioTranscodeMode;
// OpenSubtitles API key for client-side subtitle fetching
openSubtitlesApiKey?: string;
+ // TV-only: Inactivity timeout for auto-logout
+ inactivityTimeout: InactivityTimeout;
};
export interface Lockable {
@@ -280,6 +315,13 @@ export const defaultValues: Settings = {
mpvSubtitleAlignX: undefined,
mpvSubtitleAlignY: undefined,
mpvSubtitleFontSize: undefined,
+ mpvSubtitleBackgroundEnabled: false,
+ mpvSubtitleBackgroundOpacity: 75,
+ // MPV buffer/cache defaults
+ mpvCacheEnabled: "auto",
+ mpvCacheSeconds: 10,
+ mpvDemuxerMaxBytes: 150, // MB
+ mpvDemuxerMaxBackBytes: 50, // MB
// Gesture controls
enableHorizontalSwipeSkip: true,
enableLeftSideBrightnessSwipe: true,
@@ -287,10 +329,13 @@ export const defaultValues: Settings = {
hideVolumeSlider: false,
hideBrightnessSlider: false,
usePopularPlugin: true,
- showLargeHomeCarousel: false,
mergeNextUpAndContinueWatching: false,
// TV-specific settings
showHomeBackdrop: true,
+ showTVHeroCarousel: true,
+ tvTypographyScale: TVTypographyScale.Default,
+ showSeriesPosterOnEpisode: false,
+ tvThemeMusicEnabled: true,
// Appearance
hideRemoteSessionButton: false,
hideWatchlistsTab: false,
@@ -302,6 +347,8 @@ export const defaultValues: Settings = {
preferLocalAudio: true,
// Audio transcoding mode
audioTranscodeMode: AudioTranscodeMode.Auto,
+ // TV-only: Inactivity timeout (disabled by default)
+ inactivityTimeout: InactivityTimeout.Disabled,
};
const loadSettings = (): Partial => {
diff --git a/utils/atoms/tvAccountActionModal.ts b/utils/atoms/tvAccountActionModal.ts
new file mode 100644
index 00000000..c9532a7f
--- /dev/null
+++ b/utils/atoms/tvAccountActionModal.ts
@@ -0,0 +1,14 @@
+import { atom } from "jotai";
+import type {
+ SavedServer,
+ SavedServerAccount,
+} from "@/utils/secureCredentials";
+
+export type TVAccountActionModalState = {
+ server: SavedServer;
+ account: SavedServerAccount;
+ onLogin: () => void;
+ onDelete: () => void;
+} | null;
+
+export const tvAccountActionModalAtom = atom(null);
diff --git a/utils/atoms/tvAccountSelectModal.ts b/utils/atoms/tvAccountSelectModal.ts
new file mode 100644
index 00000000..9fd8bf20
--- /dev/null
+++ b/utils/atoms/tvAccountSelectModal.ts
@@ -0,0 +1,14 @@
+import { atom } from "jotai";
+import type {
+ SavedServer,
+ SavedServerAccount,
+} from "@/utils/secureCredentials";
+
+export type TVAccountSelectModalState = {
+ server: SavedServer;
+ onAccountAction: (account: SavedServerAccount) => void;
+ onAddAccount: () => void;
+ onDeleteServer: () => void;
+} | null;
+
+export const tvAccountSelectModalAtom = atom(null);
diff --git a/utils/atoms/tvUserSwitchModal.ts b/utils/atoms/tvUserSwitchModal.ts
new file mode 100644
index 00000000..2df72df1
--- /dev/null
+++ b/utils/atoms/tvUserSwitchModal.ts
@@ -0,0 +1,12 @@
+import { atom } from "jotai";
+import type { SavedServerAccount } from "@/utils/secureCredentials";
+
+export type TVUserSwitchModalState = {
+ serverUrl: string;
+ serverName: string;
+ accounts: SavedServerAccount[];
+ currentUserId: string;
+ onAccountSelect: (account: SavedServerAccount) => void;
+} | null;
+
+export const tvUserSwitchModalAtom = atom(null);
diff --git a/utils/jellyfin/audio/getAudioStreamUrl.ts b/utils/jellyfin/audio/getAudioStreamUrl.ts
index df140d03..f8eb2629 100644
--- a/utils/jellyfin/audio/getAudioStreamUrl.ts
+++ b/utils/jellyfin/audio/getAudioStreamUrl.ts
@@ -1,7 +1,7 @@
import type { Api } from "@jellyfin/sdk";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
-import trackPlayerProfile from "@/utils/profiles/trackplayer";
+import trackPlayerProfile from "../../profiles/trackplayer";
export interface AudioStreamResult {
url: string;
diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts
index fba1dbe1..bfbfb526 100644
--- a/utils/jellyfin/getDefaultPlaySettings.ts
+++ b/utils/jellyfin/getDefaultPlaySettings.ts
@@ -12,7 +12,9 @@
import type {
BaseItemDto,
MediaSourceInfo,
+ MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
+import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { BITRATES } from "@/components/BitrateSelector";
import { type Settings } from "../atoms/settings";
import {
@@ -34,17 +36,144 @@ export interface PreviousIndexes {
subtitleIndex?: number;
}
+export interface PlaySettingsOptions {
+ /** Apply language preferences from settings (used on TV) */
+ applyLanguagePreferences?: boolean;
+}
+
+/**
+ * Find a track by language code.
+ *
+ * @param streams - Available media streams
+ * @param languageCode - ISO 639-2 three-letter language code (e.g., "eng", "swe")
+ * @param streamType - Type of stream to search ("Audio" or "Subtitle")
+ * @param forcedOnly - If true, only match forced subtitles
+ * @returns The stream index if found, undefined otherwise
+ */
+function findTrackByLanguage(
+ streams: MediaStream[],
+ languageCode: string | undefined,
+ streamType: "Audio" | "Subtitle",
+ forcedOnly = false,
+): number | undefined {
+ if (!languageCode) return undefined;
+
+ const candidates = streams.filter((s) => {
+ if (s.Type !== streamType) return false;
+ if (forcedOnly && !s.IsForced) return false;
+ // Match on ThreeLetterISOLanguageName (ISO 639-2)
+ return (
+ s.Language?.toLowerCase() === languageCode.toLowerCase() ||
+ // Fallback: some Jellyfin servers use two-letter codes in Language field
+ s.Language?.toLowerCase() === languageCode.substring(0, 2).toLowerCase()
+ );
+ });
+
+ // Prefer default track if multiple match
+ const defaultTrack = candidates.find((s) => s.IsDefault);
+ return defaultTrack?.Index ?? candidates[0]?.Index;
+}
+
+/**
+ * Apply subtitle mode logic to determine the final subtitle index.
+ *
+ * @param streams - Available media streams
+ * @param settings - User settings containing subtitleMode
+ * @param defaultIndex - The current default subtitle index
+ * @param audioLanguage - The selected audio track's language (for Smart mode)
+ * @param subtitleLanguageCode - The user's preferred subtitle language
+ * @returns The final subtitle index (-1 for disabled)
+ */
+function applySubtitleMode(
+ streams: MediaStream[],
+ settings: Settings,
+ defaultIndex: number,
+ audioLanguage: string | undefined,
+ subtitleLanguageCode: string | undefined,
+): number {
+ const subtitleStreams = streams.filter((s) => s.Type === "Subtitle");
+ const mode = settings.subtitleMode ?? SubtitlePlaybackMode.Default;
+
+ switch (mode) {
+ case SubtitlePlaybackMode.None:
+ // Always disable subtitles
+ return -1;
+
+ case SubtitlePlaybackMode.OnlyForced: {
+ // Only show forced subtitles, prefer matching language
+ const forcedMatch = findTrackByLanguage(
+ streams,
+ subtitleLanguageCode,
+ "Subtitle",
+ true,
+ );
+ if (forcedMatch !== undefined) return forcedMatch;
+ // Fallback to any forced subtitle
+ const anyForced = subtitleStreams.find((s) => s.IsForced);
+ return anyForced?.Index ?? -1;
+ }
+
+ case SubtitlePlaybackMode.Always: {
+ // Always enable subtitles, prefer language match
+ const alwaysMatch = findTrackByLanguage(
+ streams,
+ subtitleLanguageCode,
+ "Subtitle",
+ );
+ if (alwaysMatch !== undefined) return alwaysMatch;
+ // Fallback to first available or current default
+ return subtitleStreams[0]?.Index ?? defaultIndex;
+ }
+
+ case SubtitlePlaybackMode.Smart: {
+ // Enable subtitles only when audio language differs from subtitle preference
+ if (audioLanguage && subtitleLanguageCode) {
+ const audioLang = audioLanguage.toLowerCase();
+ const subLang = subtitleLanguageCode.toLowerCase();
+ // If audio matches subtitle preference, disable subtitles
+ if (
+ audioLang === subLang ||
+ audioLang.startsWith(subLang.substring(0, 2)) ||
+ subLang.startsWith(audioLang.substring(0, 2))
+ ) {
+ return -1;
+ }
+ }
+ // Audio doesn't match preference, enable subtitles
+ const smartMatch = findTrackByLanguage(
+ streams,
+ subtitleLanguageCode,
+ "Subtitle",
+ );
+ return smartMatch ?? subtitleStreams[0]?.Index ?? -1;
+ }
+ default:
+ // Use language preference if set, else keep Jellyfin default
+ if (subtitleLanguageCode) {
+ const langMatch = findTrackByLanguage(
+ streams,
+ subtitleLanguageCode,
+ "Subtitle",
+ );
+ if (langMatch !== undefined) return langMatch;
+ }
+ return defaultIndex;
+ }
+}
+
/**
* Get default play settings for an item.
*
* @param item - The media item to play
* @param settings - User settings (language preferences, bitrate, etc.)
* @param previous - Optional previous track selections to carry over (for sequential play)
+ * @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV)
*/
export function getDefaultPlaySettings(
item: BaseItemDto | null | undefined,
settings: Settings | null,
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
+ options?: PlaySettingsOptions,
): PlaySettings {
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
@@ -65,6 +194,10 @@ export function getDefaultPlaySettings(
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
+ // Track whether we matched previous selections (for language preference fallback)
+ let matchedPreviousAudio = false;
+ let matchedPreviousSubtitle = false;
+
// Try to match previous selections (sequential play)
if (previous?.indexes && previous?.source && settings) {
if (
@@ -79,7 +212,11 @@ export function getDefaultPlaySettings(
streams,
result,
);
- subtitleIndex = result.DefaultSubtitleStreamIndex;
+ // Check if StreamRanker found a match (changed from default)
+ if (result.DefaultSubtitleStreamIndex !== subtitleIndex) {
+ subtitleIndex = result.DefaultSubtitleStreamIndex;
+ matchedPreviousSubtitle = true;
+ }
}
if (
@@ -94,7 +231,51 @@ export function getDefaultPlaySettings(
streams,
result,
);
- audioIndex = result.DefaultAudioStreamIndex;
+ // Check if StreamRanker found a match (changed from default)
+ if (result.DefaultAudioStreamIndex !== audioIndex) {
+ audioIndex = result.DefaultAudioStreamIndex;
+ matchedPreviousAudio = true;
+ }
+ }
+ }
+
+ // Apply language preferences when enabled (TV) and no previous selection matched
+ if (options?.applyLanguagePreferences && settings) {
+ const audioLanguageCode =
+ settings.defaultAudioLanguage?.ThreeLetterISOLanguageName ?? undefined;
+ const subtitleLanguageCode =
+ settings.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ?? undefined;
+
+ // Apply audio language preference if no previous selection matched
+ if (!matchedPreviousAudio && audioLanguageCode) {
+ const langMatch = findTrackByLanguage(
+ streams,
+ audioLanguageCode,
+ "Audio",
+ );
+ if (langMatch !== undefined) {
+ audioIndex = langMatch;
+ }
+ }
+
+ // Get the selected audio track's language for Smart mode
+ const selectedAudioTrack = streams.find(
+ (s) => s.Type === "Audio" && s.Index === audioIndex,
+ );
+ const selectedAudioLanguage =
+ selectedAudioTrack?.Language ??
+ selectedAudioTrack?.DisplayTitle ??
+ undefined;
+
+ // Apply subtitle mode logic if no previous selection matched
+ if (!matchedPreviousSubtitle) {
+ subtitleIndex = applySubtitleMode(
+ streams,
+ settings,
+ subtitleIndex,
+ selectedAudioLanguage,
+ subtitleLanguageCode,
+ );
}
}
diff --git a/utils/jellyfin/image/getUserImageUrl.ts b/utils/jellyfin/image/getUserImageUrl.ts
new file mode 100644
index 00000000..89e51f6a
--- /dev/null
+++ b/utils/jellyfin/image/getUserImageUrl.ts
@@ -0,0 +1,32 @@
+/**
+ * Retrieves the profile image URL for a Jellyfin user.
+ *
+ * @param serverAddress - The Jellyfin server base URL.
+ * @param userId - The user's ID.
+ * @param primaryImageTag - The user's primary image tag (required for the image to exist).
+ * @param width - The desired image width (default: 280).
+ * @returns The image URL or null if no image tag is provided.
+ */
+export const getUserImageUrl = ({
+ serverAddress,
+ userId,
+ primaryImageTag,
+ width = 280,
+}: {
+ serverAddress: string;
+ userId: string;
+ primaryImageTag?: string | null;
+ width?: number;
+}): string | null => {
+ if (!primaryImageTag) {
+ return null;
+ }
+
+ const params = new URLSearchParams({
+ tag: primaryImageTag,
+ quality: "90",
+ width: String(width),
+ });
+
+ return `${serverAddress}/Users/${userId}/Images/Primary?${params.toString()}`;
+};
diff --git a/utils/jellyfin/media/getDownloadUrl.ts b/utils/jellyfin/media/getDownloadUrl.ts
index 223c73c1..a63353b2 100644
--- a/utils/jellyfin/media/getDownloadUrl.ts
+++ b/utils/jellyfin/media/getDownloadUrl.ts
@@ -7,7 +7,7 @@ import { Bitrate } from "@/components/BitrateSelector";
import {
type AudioTranscodeModeType,
generateDeviceProfile,
-} from "@/utils/profiles/native";
+} from "../../profiles/native";
import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl";
export const getDownloadUrl = async ({
diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts
index c2124720..8fe02df0 100644
--- a/utils/jellyfin/media/getStreamUrl.ts
+++ b/utils/jellyfin/media/getStreamUrl.ts
@@ -5,13 +5,14 @@ import type {
} from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
-import { generateDownloadProfile } from "@/utils/profiles/download";
-import type { AudioTranscodeModeType } from "@/utils/profiles/native";
+import { generateDownloadProfile } from "../../profiles/download";
+import type { AudioTranscodeModeType } from "../../profiles/native";
interface StreamResult {
url: string;
sessionId: string | null;
mediaSource: MediaSourceInfo | undefined;
+ requiredHttpHeaders?: Record;
}
/**
@@ -50,10 +51,24 @@ const getPlaybackUrl = (
return `${api.basePath}${transcodeUrl}`;
}
+ // Handle remote/external streams (like live TV with external URLs)
+ // These have Protocol "Http" and IsRemote true, with the actual URL in Path
+ if (
+ mediaSource?.IsRemote &&
+ mediaSource?.Protocol === "Http" &&
+ mediaSource?.Path
+ ) {
+ console.log("Video is remote stream, using direct Path:", mediaSource.Path);
+ return mediaSource.Path;
+ }
+
// Fall back to direct play
+ // Use the mediaSource's actual container when available (important for live TV
+ // where the container may be ts/hls, not mp4)
+ const container = params.container || mediaSource?.Container || "mp4";
const streamParams = new URLSearchParams({
static: params.static || "true",
- container: params.container || "mp4",
+ container,
mediaSourceId: mediaSource?.Id || "",
subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "",
audioStreamIndex: params.audioStreamIndex?.toString() || "",
@@ -163,6 +178,7 @@ export const getStreamUrl = async ({
url: string | null;
sessionId: string | null;
mediaSource: MediaSourceInfo | undefined;
+ requiredHttpHeaders?: Record;
} | null> => {
if (!api || !userId || !item?.Id) {
console.warn("Missing required parameters for getStreamUrl");
@@ -210,6 +226,9 @@ export const getStreamUrl = async ({
url,
sessionId: sessionId || null,
mediaSource,
+ requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as
+ | Record
+ | undefined,
};
}
@@ -254,6 +273,9 @@ export const getStreamUrl = async ({
url,
sessionId: sessionId || null,
mediaSource,
+ requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as
+ | Record
+ | undefined,
};
};
diff --git a/utils/opensubtitles/api.ts b/utils/opensubtitles/api.ts
index d9101cf8..23059198 100644
--- a/utils/opensubtitles/api.ts
+++ b/utils/opensubtitles/api.ts
@@ -87,6 +87,58 @@ export class OpenSubtitlesApiError extends Error {
}
}
+/**
+ * Mapping between ISO 639-1 (2-letter) and ISO 639-2B (3-letter) language codes
+ */
+const ISO_639_MAPPING: Record = {
+ en: "eng",
+ es: "spa",
+ fr: "fre",
+ de: "ger",
+ it: "ita",
+ pt: "por",
+ ru: "rus",
+ ja: "jpn",
+ ko: "kor",
+ zh: "chi",
+ ar: "ara",
+ pl: "pol",
+ nl: "dut",
+ sv: "swe",
+ no: "nor",
+ da: "dan",
+ fi: "fin",
+ tr: "tur",
+ cs: "cze",
+ el: "gre",
+ he: "heb",
+ hu: "hun",
+ ro: "rum",
+ th: "tha",
+ vi: "vie",
+ id: "ind",
+ ms: "may",
+ bg: "bul",
+ hr: "hrv",
+ sk: "slo",
+ sl: "slv",
+ uk: "ukr",
+};
+
+// Reverse mapping: 3-letter to 2-letter
+const ISO_639_REVERSE: Record = Object.fromEntries(
+ Object.entries(ISO_639_MAPPING).map(([k, v]) => [v, k]),
+);
+
+/**
+ * Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code
+ * OpenSubtitles REST API uses 2-letter codes
+ */
+function toIso6391(code: string): string {
+ if (code.length === 2) return code;
+ return ISO_639_REVERSE[code.toLowerCase()] || code;
+}
+
/**
* OpenSubtitles API client for direct subtitle fetching
*/
@@ -138,7 +190,7 @@ export class OpenSubtitlesApi {
const queryParams = new URLSearchParams();
if (params.imdbId) {
- // Ensure IMDB ID has correct format (with "tt" prefix)
+ // Ensure IMDB ID has "tt" prefix
const imdbId = params.imdbId.startsWith("tt")
? params.imdbId
: `tt${params.imdbId}`;
@@ -151,7 +203,12 @@ export class OpenSubtitlesApi {
queryParams.set("year", params.year.toString());
}
if (params.languages) {
- queryParams.set("languages", params.languages);
+ // Convert 3-letter codes to 2-letter codes (API uses ISO 639-1)
+ const lang =
+ params.languages.length === 3
+ ? toIso6391(params.languages)
+ : params.languages;
+ queryParams.set("languages", lang);
}
if (params.seasonNumber !== undefined) {
queryParams.set("season_number", params.seasonNumber.toString());
@@ -179,50 +236,18 @@ export class OpenSubtitlesApi {
}
}
+/**
+ * Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code
+ * Exported for external use
+ */
+export { toIso6391 };
+
/**
* Convert ISO 639-1 (2-letter) to ISO 639-2B (3-letter) language code
- * OpenSubtitles uses ISO 639-2B codes
*/
export function toIso6392B(code: string): string {
- const mapping: Record = {
- en: "eng",
- es: "spa",
- fr: "fre",
- de: "ger",
- it: "ita",
- pt: "por",
- ru: "rus",
- ja: "jpn",
- ko: "kor",
- zh: "chi",
- ar: "ara",
- pl: "pol",
- nl: "dut",
- sv: "swe",
- no: "nor",
- da: "dan",
- fi: "fin",
- tr: "tur",
- cs: "cze",
- el: "gre",
- he: "heb",
- hu: "hun",
- ro: "rum",
- th: "tha",
- vi: "vie",
- id: "ind",
- ms: "may",
- bg: "bul",
- hr: "hrv",
- sk: "slo",
- sl: "slv",
- uk: "ukr",
- };
-
- // If already 3 letters, return as-is
if (code.length === 3) return code;
-
- return mapping[code.toLowerCase()] || code;
+ return ISO_639_MAPPING[code.toLowerCase()] || code;
}
/**
diff --git a/utils/profiles/index.ts b/utils/profiles/index.ts
new file mode 100644
index 00000000..9ec48ada
--- /dev/null
+++ b/utils/profiles/index.ts
@@ -0,0 +1,6 @@
+export { chromecast } from "./chromecast";
+export { chromecasth265 } from "./chromecasth265";
+export { generateDownloadProfile } from "./download";
+export * from "./native";
+export { default } from "./native";
+export { default as trackPlayerProfile } from "./trackplayer";
diff --git a/utils/profiles/native.d.ts b/utils/profiles/native.d.ts
deleted file mode 100644
index 43489710..00000000
--- a/utils/profiles/native.d.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-
-export type PlatformType = "ios" | "android";
-export type PlayerType = "mpv";
-export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
-
-export interface ProfileOptions {
- /** Target platform */
- platform?: PlatformType;
- /** Video player being used */
- player?: PlayerType;
- /** Audio transcoding mode */
- audioMode?: AudioTranscodeModeType;
-}
-
-export function generateDeviceProfile(options?: ProfileOptions): any;
-
-declare const _default: any;
-export default _default;
diff --git a/utils/profiles/native.js b/utils/profiles/native.ts
similarity index 83%
rename from utils/profiles/native.js
rename to utils/profiles/native.ts
index ec74f4b6..9d7224ff 100644
--- a/utils/profiles/native.js
+++ b/utils/profiles/native.ts
@@ -7,22 +7,24 @@ import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
-/**
- * @typedef {"ios" | "android"} PlatformType
- * @typedef {"mpv"} PlayerType
- * @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
- *
- * @typedef {Object} ProfileOptions
- * @property {PlatformType} [platform] - Target platform
- * @property {PlayerType} [player] - Video player being used (MPV only)
- * @property {AudioTranscodeModeType} [audioMode] - Audio transcoding mode
- */
+export type PlatformType = "ios" | "android";
+export type PlayerType = "mpv";
+export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
+
+export interface ProfileOptions {
+ /** Target platform */
+ platform?: PlatformType;
+ /** Video player being used */
+ player?: PlayerType;
+ /** Audio transcoding mode */
+ audioMode?: AudioTranscodeModeType;
+}
/**
* Audio direct play profiles for standalone audio items in MPV player.
* These define which audio file formats can be played directly without transcoding.
*/
-const getAudioDirectPlayProfile = (platform) => {
+const getAudioDirectPlayProfile = (platform: PlatformType) => {
if (platform === "ios") {
// iOS audio formats supported by MPV
return {
@@ -44,7 +46,7 @@ const getAudioDirectPlayProfile = (platform) => {
* Audio codec profiles for standalone audio items in MPV player.
* These define codec constraints for audio file playback.
*/
-const getAudioCodecProfile = (platform) => {
+const getAudioCodecProfile = (platform: PlatformType) => {
if (platform === "ios") {
// iOS audio codec constraints for MPV
return {
@@ -66,12 +68,11 @@ const getAudioCodecProfile = (platform) => {
* MPV (via FFmpeg) can decode all audio codecs including TrueHD and DTS-HD MA.
* The audioMode setting only controls the maximum channel count - MPV will
* decode and downmix as needed.
- *
- * @param {PlatformType} platform
- * @param {AudioTranscodeModeType} audioMode
- * @returns {{ directPlayCodec: string, maxAudioChannels: string }}
*/
-const getVideoAudioCodecs = (platform, audioMode) => {
+const getVideoAudioCodecs = (
+ platform: PlatformType,
+ audioMode: AudioTranscodeModeType,
+): { directPlayCodec: string; maxAudioChannels: string } => {
// Base codecs
const baseCodecs = "aac,mp3,flac,opus,vorbis";
@@ -120,12 +121,9 @@ const getVideoAudioCodecs = (platform, audioMode) => {
/**
* Generates a device profile for Jellyfin playback.
- *
- * @param {ProfileOptions} [options] - Profile configuration options
- * @returns {Object} Jellyfin device profile
*/
-export const generateDeviceProfile = (options = {}) => {
- const platform = options.platform || Platform.OS;
+export const generateDeviceProfile = (options: ProfileOptions = {}) => {
+ const platform = (options.platform || Platform.OS) as PlatformType;
const audioMode = options.audioMode || "auto";
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
diff --git a/utils/secureCredentials.ts b/utils/secureCredentials.ts
index f64a56d7..bb5f7713 100644
--- a/utils/secureCredentials.ts
+++ b/utils/secureCredentials.ts
@@ -22,6 +22,7 @@ export interface ServerCredential {
savedAt: number;
securityType: AccountSecurityType;
pinHash?: string;
+ primaryImageTag?: string;
}
/**
@@ -32,6 +33,7 @@ export interface SavedServerAccount {
username: string;
securityType: AccountSecurityType;
savedAt: number;
+ primaryImageTag?: string;
}
/**
@@ -131,6 +133,7 @@ export async function saveAccountCredential(
username: credential.username,
securityType: credential.securityType,
savedAt: credential.savedAt,
+ primaryImageTag: credential.primaryImageTag,
});
}
@@ -224,7 +227,7 @@ export async function clearAllCredentials(): Promise {
/**
* Add or update an account in a server's accounts list.
*/
-function addAccountToServer(
+export function addAccountToServer(
serverUrl: string,
serverName: string,
account: SavedServerAccount,
@@ -475,19 +478,32 @@ export async function migrateToMultiAccount(): Promise {
}
/**
- * Update account's token after successful login.
+ * Update account's token and optionally other fields after successful login.
*/
export async function updateAccountToken(
serverUrl: string,
userId: string,
newToken: string,
+ primaryImageTag?: string,
): Promise {
const credential = await getAccountCredential(serverUrl, userId);
if (credential) {
credential.token = newToken;
credential.savedAt = Date.now();
+ if (primaryImageTag !== undefined) {
+ credential.primaryImageTag = primaryImageTag;
+ }
const key = credentialKey(serverUrl, userId);
await SecureStore.setItemAsync(key, JSON.stringify(credential));
+
+ // Also update the account info in the server list
+ addAccountToServer(serverUrl, credential.serverName, {
+ userId: credential.userId,
+ username: credential.username,
+ securityType: credential.securityType,
+ savedAt: credential.savedAt,
+ primaryImageTag: credential.primaryImageTag,
+ });
}
}