Compare commits

..

1 Commits

Author SHA1 Message Date
Lance Chant
715daf1635 feat: skip intro
Added skip intro logic
Updated control button to take an icon or text

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-27 09:01:32 +02:00
258 changed files with 4946 additions and 15921 deletions

View File

@@ -1,103 +0,0 @@
---
name: tv-validator
description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements.
tools: Read, Glob, Grep
model: haiku
color: blue
---
You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations.
## Critical Rules to Check
### 1. No .tv.tsx File Suffix
The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it.
**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution
**Correct**: Use `Platform.isTV` conditional rendering in the main file:
```typescript
if (Platform.isTV) {
return <TVMyComponent />;
}
return <MyComponent />;
```
### 2. No FlashList on TV
FlashList has focus issues on TV. Use FlatList instead.
**Violation**: `<FlashList` in TV code paths
**Correct**:
```typescript
{Platform.isTV ? (
<FlatList removeClippedSubviews={false} ... />
) : (
<FlashList ... />
)}
```
### 3. Modal Pattern
Never use overlay/absolute-positioned modals on TV. They break back button handling.
**Violation**: `position: "absolute"` or `Modal` component for TV overlays
**Correct**: Use navigation-based pattern:
- Create Jotai atom for state
- Hook that sets atom and calls `router.push()`
- Page in `app/(auth)/` that reads atom
- `Stack.Screen` with `presentation: "transparentModal"`
### 4. Typography
All TV text must use `TVTypography` component.
**Violation**: Raw `<Text>` in TV components
**Correct**: `<TVTypography variant="title">...</TVTypography>`
### 5. No Purple Accent Colors
TV uses white for focus states, not purple.
**Violation**: Purple/violet colors in TV focused states
**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds
### 6. Focus Handling
- Only ONE element should have `hasTVPreferredFocus={true}`
- Focusable items need `disabled={isModalOpen}` when overlays are visible
- Use `onFocus`/`onBlur` with scale animations
- Add padding for scale animations (focus scale clips without it)
### 7. List Configuration
TV lists need:
- `removeClippedSubviews={false}`
- `overflow: "visible"` on containers
- Sufficient padding for focus scale animations
### 8. Horizontal Padding
Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20).
### 9. Focus Guide Navigation
For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop.
Use `useState` for refs (not `useRef`) to trigger re-renders.
## Review Process
1. Read the file(s) to review
2. Check each rule above
3. Report violations with:
- Line number
- What's wrong
- How to fix it
4. If no violations, confirm the code follows TV patterns
## Output Format
```
## TV Validation Results
### ✓ Passes
- [List of rules that pass]
### ✗ Violations
- **[Rule Name]** (line X): [Description]
Fix: [How to correct it]
### Recommendations
- [Optional suggestions for improvement]
```

View File

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

View File

@@ -1,11 +1,8 @@
# Learned Facts (DEPRECATED) # Learned Facts
> **DEPRECATED**: This file has been replaced by individual fact files in `.claude/learned-facts/`. 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.
> The compressed index is now inline in `CLAUDE.md` under "Learned Facts Index".
> New facts should be added as individual files using the `/reflect` command.
> This file is kept for reference only and is no longer auto-imported.
This file previously contained facts about the codebase learned from past sessions. This file is auto-imported into CLAUDE.md and loaded at the start of each session.
## Facts ## Facts
@@ -44,5 +41,3 @@ This file previously contained facts about the codebase learned from past sessio
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_ - **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ - **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_

View File

@@ -1,9 +0,0 @@
# Header Button Locations
**Date**: 2026-01-10
**Category**: ui
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`, `components/common/HeaderBackButton.tsx`, `components/Chromecast.tsx`, `components/RoundButton.tsx`, `components/home/Home.tsx`, `app/(auth)/(tabs)/(home)/downloads/index.tsx`
## Detail
Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`.

View File

@@ -1,9 +0,0 @@
# Intro Modal Trigger Location
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `components/home/Home.tsx`, `app/(auth)/(tabs)/_layout.tsx`
## Detail
The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation.

View File

@@ -1,9 +0,0 @@
# IntroSheet Rendering Location
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `providers/IntroSheetProvider`, `components/IntroSheet`
## Detail
The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs.

View File

@@ -1,9 +0,0 @@
# macOS Header Buttons Fix
**Date**: 2026-01-10
**Category**: ui
**Key files**: `components/common/HeaderBackButton.tsx`, `app/(auth)/(tabs)/(home)/_layout.tsx`
## Detail
Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app.

View File

@@ -1,9 +0,0 @@
# Mark as Played Flow
**Date**: 2026-01-10
**Category**: state-management
**Key files**: `components/PlayedStatus.tsx`, `hooks/useMarkAsPlayed.ts`
## Detail
The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`.

View File

@@ -1,9 +0,0 @@
# MPV avfoundation-composite-osd Ordering
**Date**: 2026-01-22
**Category**: native-modules
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
## Detail
On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support).

View File

@@ -1,9 +0,0 @@
# MPV tvOS Player Exit Freeze
**Date**: 2026-01-22
**Category**: native-modules
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
## Detail
On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first.

View File

@@ -1,9 +0,0 @@
# Native Bottom Tabs + useRouter Conflict
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `providers/`, `app/_layout.tsx`
## Detail
When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead.

View File

@@ -1,9 +0,0 @@
# Native SwiftUI View Sizing
**Date**: 2026-01-25
**Category**: native-modules
**Key files**: `modules/`
## Detail
When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing.

View File

@@ -1,9 +0,0 @@
# Platform-Specific File Suffix (.tv.tsx) Does NOT Work
**Date**: 2026-01-26
**Category**: tv
**Key files**: `app/`, `components/`
## Detail
The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render.

View File

@@ -1,9 +0,0 @@
# Stack Screen Header Configuration
**Date**: 2026-01-10
**Category**: ui
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`
## Detail
Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling.

View File

@@ -1,9 +0,0 @@
# Streamystats Components Location
**Date**: 2026-01-25
**Category**: tv
**Key files**: `components/home/StreamystatsRecommendations.tv.tsx`, `components/home/StreamystatsPromotedWatchlists.tv.tsx`, `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`
## Detail
Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`.

View File

@@ -1,9 +0,0 @@
# Tab Folder Naming
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `app/(auth)/(tabs)/`
## Detail
The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions.

View File

@@ -1,9 +0,0 @@
# Thread-Safe State for Stop Flags
**Date**: 2026-01-22
**Category**: native-modules
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
## Detail
When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time.

View File

@@ -1,9 +0,0 @@
# TV Grid Layout Pattern
**Date**: 2026-01-25
**Category**: tv
**Key files**: `components/tv/`
## Detail
For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing.

View File

@@ -1,9 +0,0 @@
# TV Horizontal Padding Standard
**Date**: 2026-01-25
**Category**: tv
**Key files**: `components/tv/`, `app/(auth)/(tabs)/`
## Detail
TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small.

View File

@@ -1,9 +0,0 @@
# TV Modals Must Use Navigation Pattern
**Date**: 2026-01-24
**Category**: tv
**Key files**: `hooks/useTVOptionModal.ts`, `app/(auth)/tv-option-modal.tsx`
## Detail
On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page.

View File

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

2
.gitignore vendored
View File

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

136
CLAUDE.md
View File

@@ -1,39 +1,9 @@
# CLAUDE.md # CLAUDE.md
@.claude/learned-facts.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Learned Facts Index
IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in `.claude/learned-facts/` before relying on assumptions.
Navigation:
- `native-bottom-tabs-userouter-conflict` | useRouter() at provider level causes tab switches; use static router import
- `introsheet-rendering-location` | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks
- `intro-modal-trigger-location` | Trigger in Home.tsx, not tabs _layout.tsx
- `tab-folder-naming` | Use underscore prefix: (_home) not (home)
UI/Headers:
- `macos-header-buttons-fix` | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity
- `header-button-locations` | Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc.
- `stack-screen-header-configuration` | Sub-pages need explicit Stack.Screen with headerTransparent + back button
State/Data:
- `use-network-aware-query-client-limitations` | Object.create breaks private fields; only for invalidateQueries
- `mark-as-played-flow` | PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates
Native Modules:
- `mpv-tvos-player-exit-freeze` | mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global()
- `mpv-avfoundation-composite-osd-ordering` | MUST follow vo=avfoundation, before hwdec options
- `thread-safe-state-for-stop-flags` | Stop flags need synchronous setter (stateQueue.sync not async)
- `native-swiftui-view-sizing` | Need explicit frame + intrinsicContentSize override in ExpoView
TV Platform:
- `tv-modals-must-use-navigation-pattern` | Use atom+router.push(), never overlay/absolute modals
- `tv-grid-layout-pattern` | ScrollView+flexWrap, not FlatList numColumns
- `tv-horizontal-padding-standard` | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20
- `streamystats-components-location` | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx
- `platform-specific-file-suffix-does-not-work` | .tv.tsx doesn't work; use Platform.isTV conditional rendering
## Project Overview ## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration. Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
@@ -95,7 +65,6 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
**State Management**: **State Management**:
- Global state uses Jotai atoms in `utils/atoms/` - Global state uses Jotai atoms in `utils/atoms/`
- `settingsAtom` in `utils/atoms/settings.ts` for app settings - `settingsAtom` in `utils/atoms/settings.ts` for app settings
- **IMPORTANT**: When adding a setting to the settings atom, ensure it's toggleable in the settings view (either TV or mobile, depending on the feature scope)
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state - `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
- Server state uses React Query with `@tanstack/react-query` - Server state uses React Query with `@tanstack/react-query`
@@ -159,7 +128,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- Handle both mobile and TV navigation patterns - Handle both mobile and TV navigation patterns
- Use existing atoms, hooks, and utilities before creating new ones - Use existing atoms, hooks, and utilities before creating new ones
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):` - Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
- **Translations**: When adding a translation key to a Text component, ensure the key exists in both `translations/en.json` and `translations/sv.json`. Before adding new keys, check if an existing key already covers the use case.
## Platform Considerations ## Platform Considerations
@@ -170,13 +138,13 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance. - **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency. - **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency.
- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping. - **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
- **TV Modals**: Never use 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 Modals**: Never use overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference.
### TV Component Rendering Pattern ### TV Component Rendering Pattern
**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead. **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.
**Pattern for TV-specific pages and components**: **Pattern for TV-specific components**:
```typescript ```typescript
// In page file (e.g., app/login.tsx) // In page file (e.g., app/login.tsx)
import { Platform } from "react-native"; import { Platform } from "react-native";
@@ -196,11 +164,99 @@ export default LoginPage;
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`) - Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
- Use `Platform.isTV` to conditionally render the appropriate component - Use `Platform.isTV` to conditionally render the appropriate component
- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling - TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling
- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly
### TV Option Selectors and Focus Management ### TV Option Selector Pattern (Dropdowns/Multi-select)
For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md). For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because:
- Horizontal scrolling is natural for TV remotes (left/right D-pad)
- Bottom sheet takes minimal screen space
- Focus-based navigation works reliably
**Key implementation details:**
1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain. Use an absolutely positioned `View` overlay instead:
```typescript
<View style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}>
<BlurView intensity={80} tint="dark" style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24 }}>
{/* Content */}
</BlurView>
</View>
```
2. **Horizontal ScrollView with focusable cards**:
```typescript
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ paddingHorizontal: 48, paddingVertical: 10, gap: 12 }}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
hasTVPreferredFocus={index === selectedIndex}
onPress={() => { onSelect(option.value); onClose(); }}
// ...
/>
))}
</ScrollView>
```
3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`:
```typescript
<Pressable
onPress={onPress}
onFocus={() => { setFocused(true); animateTo(1.05); }}
onBlur={() => { setFocused(false); animateTo(1); }}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View style={{ transform: [{ scale }], backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)" }}>
<Text style={{ color: focused ? "#000" : "#fff" }}>{label}</Text>
</Animated.View>
</Pressable>
```
4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip.
**Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`
### TV Focus Management for Overlays/Modals
**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation.
**Solution**: Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible:
```typescript
// 1. Track modal state
const [openModal, setOpenModal] = useState<ModalType | null>(null);
const isModalOpen = openModal !== null;
// 2. Each focusable component accepts disabled prop
const TVFocusableButton: React.FC<{
onPress: () => void;
disabled?: boolean;
}> = ({ onPress, disabled }) => (
<Pressable
onPress={onPress}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={isFirst && !disabled}
>
{/* content */}
</Pressable>
);
// 3. Pass disabled to all background components when modal is open
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
```
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
### TV Focus Flickering Between Zones (Lists with Headers) ### TV Focus Flickering Between Zones (Lists with Headers)

View File

@@ -126,10 +126,6 @@ For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv` `npm run prebuild:tv`
`npm run ios:tv or npm run android:tv` `npm run ios:tv or npm run android:tv`
TV platform integration notes:
- [TV Discovery](./docs/tv-discovery.md)
## 👋 Get in Touch with Us ## 👋 Get in Touch with Us
Need assistance or have any questions? Need assistance or have any questions?

View File

@@ -6,14 +6,6 @@ module.exports = ({ config }) => {
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
config.plugins.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
} }
// Only override googleServicesFile if env var is set // Only override googleServicesFile if env var is set

View File

@@ -7,6 +7,8 @@
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"requireFullScreen": true, "requireFullScreen": true,
@@ -36,6 +38,7 @@
"appleTeamId": "MWD5K362T8" "appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes",
"versionCode": 92, "versionCode": 92,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
@@ -73,7 +76,6 @@
"expo-router", "expo-router",
"expo-font", "expo-font",
"./plugins/withExcludeMedia3Dash.js", "./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[ [
"expo-build-properties", "expo-build-properties",
{ {
@@ -82,7 +84,7 @@
"useFrameworks": "static" "useFrameworks": "static"
}, },
"android": { "android": {
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"], "buildArchs": ["arm64-v8a", "x86_64"],
"compileSdkVersion": 36, "compileSdkVersion": 36,
"targetSdkVersion": 35, "targetSdkVersion": 35,
"buildToolsVersion": "35.0.0", "buildToolsVersion": "35.0.0",
@@ -133,12 +135,10 @@
"expo-web-browser", "expo-web-browser",
["./plugins/with-runtime-framework-headers.js"], ["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"], ["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"], ["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"], ["./plugins/withTVXcodeEnv.js"],
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.js",

View File

@@ -96,25 +96,6 @@ export default function IndexLayout() {
), ),
}} }}
/> />
<Stack.Screen
name='companion-login'
options={{
title: t("companion_login.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen <Stack.Screen
name='settings/playback-controls/page' name='settings/playback-controls/page'
options={{ options={{

View File

@@ -1,7 +0,0 @@
import { Platform } from "react-native";
import { CompanionLoginScreen } from "@/components/companion/CompanionLoginScreen";
export default function CompanionLoginPage() {
if (Platform.isTV) return null;
return <CompanionLoginScreen />;
}

View File

@@ -59,18 +59,6 @@ function SettingsMobile() {
<QuickConnect className='mb-4' /> <QuickConnect className='mb-4' />
<TouchableOpacity
className='mb-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800'
onPress={() => router.push("/(auth)/(tabs)/(home)/companion-login")}
>
<Text className='text-white font-bold text-base mb-1'>
{t("pairing.pair_with_phone_title")}
</Text>
<Text className='text-neutral-400 text-sm'>
{t("pairing.pair_with_phone_description")}
</Text>
</TouchableOpacity>
<View className='mb-4'> <View className='mb-4'>
<AppLanguageSelector /> <AppLanguageSelector />
</View> </View>

View File

@@ -2,11 +2,9 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
import type { TVOptionItem } from "@/components/tv"; import type { TVOptionItem } from "@/components/tv";
import { import {
TVLogoutButton, TVLogoutButton,
@@ -19,32 +17,21 @@ import {
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { import {
AudioTranscodeMode, AudioTranscodeMode,
InactivityTimeout,
type MpvCacheMode,
type MpvVoDriver,
TVTypographyScale, TVTypographyScale,
useSettings, useSettings,
} from "@/utils/atoms/settings"; } from "@/utils/atoms/settings";
import {
getPreviousServers,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
export default function SettingsTV() { export default function SettingsTV() {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin(); const { logout } = useJellyfin();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { showOptions } = useTVOptionModal(); const { showOptions } = useTVOptionModal();
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
// Local state for OpenSubtitles API key (only commit on blur) // Local state for OpenSubtitles API key (only commit on blur)
@@ -52,117 +39,6 @@ export default function SettingsTV() {
settings.openSubtitlesApiKey || "", settings.openSubtitlesApiKey || "",
); );
// PIN/Password modal state for user switching
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Track if any modal is open to disable background focus
const isAnyModalOpen = pinModalVisible || passwordModalVisible;
// Get current server and other accounts
const currentServer = useMemo(() => {
if (!api?.basePath) return null;
const servers = getPreviousServers();
return servers.find((s) => s.address === api.basePath) || null;
}, [api?.basePath]);
const otherAccounts = useMemo(() => {
if (!currentServer || !user?.Id) return [];
return currentServer.accounts.filter(
(account) => account.userId !== user.Id,
);
}, [currentServer, user?.Id]);
const hasOtherAccounts = otherAccounts.length > 0;
// Handle account selection from modal
const handleAccountSelect = async (account: SavedServerAccount) => {
if (!currentServer) return;
if (account.securityType === "none") {
// Direct login with saved credential
try {
await loginWithSavedCredential(currentServer.address, account.userId);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
);
}
} else if (account.securityType === "pin") {
// Show PIN modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPinModalVisible(true);
} else if (account.securityType === "password") {
// Show password modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPasswordModalVisible(true);
}
};
// Handle successful PIN entry
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (selectedServer && selectedAccount) {
try {
await loginWithSavedCredential(
selectedServer.address,
selectedAccount.userId,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
);
}
}
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle password submission
const handlePasswordSubmit = async (password: string) => {
if (selectedServer && selectedAccount) {
await loginWithPassword(
selectedServer.address,
selectedAccount.username,
password,
);
}
setPasswordModalVisible(false);
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle switch user button press
const handleSwitchUser = () => {
if (!currentServer || !user?.Id) return;
showUserSwitchModal(currentServer, user.Id, {
onAccountSelect: handleAccountSelect,
});
};
const currentAudioTranscode = const currentAudioTranscode =
settings.audioTranscodeMode || AudioTranscodeMode.Auto; settings.audioTranscodeMode || AudioTranscodeMode.Auto;
const currentSubtitleMode = const currentSubtitleMode =
@@ -171,9 +47,6 @@ export default function SettingsTV() {
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom"; const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
const currentTypographyScale = const currentTypographyScale =
settings.tvTypographyScale || TVTypographyScale.Default; settings.tvTypographyScale || TVTypographyScale.Default;
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
const currentLanguage = settings.preferedLanguage;
// Audio transcoding options // Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo( const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
@@ -265,65 +138,26 @@ export default function SettingsTV() {
[currentAlignY], [currentAlignY],
); );
// Cache mode options
const cacheModeOptions: TVOptionItem<MpvCacheMode>[] = useMemo(
() => [
{
label: t("home.settings.buffer.cache_auto"),
value: "auto",
selected: currentCacheMode === "auto",
},
{
label: t("home.settings.buffer.cache_yes"),
value: "yes",
selected: currentCacheMode === "yes",
},
{
label: t("home.settings.buffer.cache_no"),
value: "no",
selected: currentCacheMode === "no",
},
],
[t, currentCacheMode],
);
// VO driver options
const voDriverOptions: TVOptionItem<MpvVoDriver>[] = useMemo(
() => [
{
label: t("home.settings.vo_driver.gpu_next"),
value: "gpu-next",
selected: currentVoDriver === "gpu-next",
},
{
label: t("home.settings.vo_driver.gpu"),
value: "gpu",
selected: currentVoDriver === "gpu",
},
],
[t, currentVoDriver],
);
// Typography scale options // Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo( const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [ () => [
{ {
label: t("home.settings.appearance.display_size_small"), label: t("home.settings.appearance.text_size_small"),
value: TVTypographyScale.Small, value: TVTypographyScale.Small,
selected: currentTypographyScale === TVTypographyScale.Small, selected: currentTypographyScale === TVTypographyScale.Small,
}, },
{ {
label: t("home.settings.appearance.display_size_default"), label: t("home.settings.appearance.text_size_default"),
value: TVTypographyScale.Default, value: TVTypographyScale.Default,
selected: currentTypographyScale === TVTypographyScale.Default, selected: currentTypographyScale === TVTypographyScale.Default,
}, },
{ {
label: t("home.settings.appearance.display_size_large"), label: t("home.settings.appearance.text_size_large"),
value: TVTypographyScale.Large, value: TVTypographyScale.Large,
selected: currentTypographyScale === TVTypographyScale.Large, selected: currentTypographyScale === TVTypographyScale.Large,
}, },
{ {
label: t("home.settings.appearance.display_size_extra_large"), label: t("home.settings.appearance.text_size_extra_large"),
value: TVTypographyScale.ExtraLarge, value: TVTypographyScale.ExtraLarge,
selected: currentTypographyScale === TVTypographyScale.ExtraLarge, selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
}, },
@@ -331,74 +165,6 @@ export default function SettingsTV() {
[t, currentTypographyScale], [t, currentTypographyScale],
); );
// Language options
const languageOptions: TVOptionItem<string | undefined>[] = useMemo(
() => [
{
label: t("home.settings.languages.system"),
value: undefined,
selected: !currentLanguage,
},
...APP_LANGUAGES.map((lang) => ({
label: lang.label,
value: lang.value,
selected: currentLanguage === lang.value,
})),
],
[t, currentLanguage],
);
// Inactivity timeout options (TV security feature)
const currentInactivityTimeout =
settings.inactivityTimeout ?? InactivityTimeout.Disabled;
const inactivityTimeoutOptions: TVOptionItem<InactivityTimeout>[] = useMemo(
() => [
{
label: t("home.settings.security.inactivity_timeout.disabled"),
value: InactivityTimeout.Disabled,
selected: currentInactivityTimeout === InactivityTimeout.Disabled,
},
{
label: t("home.settings.security.inactivity_timeout.1_minute"),
value: InactivityTimeout.OneMinute,
selected: currentInactivityTimeout === InactivityTimeout.OneMinute,
},
{
label: t("home.settings.security.inactivity_timeout.5_minutes"),
value: InactivityTimeout.FiveMinutes,
selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.15_minutes"),
value: InactivityTimeout.FifteenMinutes,
selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.30_minutes"),
value: InactivityTimeout.ThirtyMinutes,
selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.1_hour"),
value: InactivityTimeout.OneHour,
selected: currentInactivityTimeout === InactivityTimeout.OneHour,
},
{
label: t("home.settings.security.inactivity_timeout.4_hours"),
value: InactivityTimeout.FourHours,
selected: currentInactivityTimeout === InactivityTimeout.FourHours,
},
{
label: t("home.settings.security.inactivity_timeout.24_hours"),
value: InactivityTimeout.TwentyFourHours,
selected:
currentInactivityTimeout === InactivityTimeout.TwentyFourHours,
},
],
[t, currentInactivityTimeout],
);
// Get display labels for option buttons // Get display labels for option buttons
const audioTranscodeLabel = useMemo(() => { const audioTranscodeLabel = useMemo(() => {
const option = audioTranscodeModeOptions.find((o) => o.selected); const option = audioTranscodeModeOptions.find((o) => o.selected);
@@ -422,32 +188,9 @@ export default function SettingsTV() {
const typographyScaleLabel = useMemo(() => { const typographyScaleLabel = useMemo(() => {
const option = typographyScaleOptions.find((o) => o.selected); const option = typographyScaleOptions.find((o) => o.selected);
return option?.label || t("home.settings.appearance.display_size_default"); return option?.label || t("home.settings.appearance.text_size_default");
}, [typographyScaleOptions, t]); }, [typographyScaleOptions, t]);
const cacheModeLabel = useMemo(() => {
const option = cacheModeOptions.find((o) => o.selected);
return option?.label || t("home.settings.buffer.cache_auto");
}, [cacheModeOptions, t]);
const voDriverLabel = useMemo(() => {
const option = voDriverOptions.find((o) => o.selected);
return option?.label || t("home.settings.vo_driver.gpu_next");
}, [voDriverOptions, t]);
const languageLabel = useMemo(() => {
if (!currentLanguage) return t("home.settings.languages.system");
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
return option?.label || t("home.settings.languages.system");
}, [currentLanguage, t]);
const inactivityTimeoutLabel = useMemo(() => {
const option = inactivityTimeoutOptions.find((o) => o.selected);
return (
option?.label || t("home.settings.security.inactivity_timeout.disabled")
);
}, [inactivityTimeoutOptions, t]);
return ( return (
<View style={{ flex: 1, backgroundColor: "#000000" }}> <View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
@@ -472,31 +215,6 @@ export default function SettingsTV() {
{t("home.settings.settings_title")} {t("home.settings.settings_title")}
</Text> </Text>
{/* Account Section */}
<TVSectionHeader title={t("home.settings.switch_user.account")} />
<TVSettingsOptionButton
label={t("home.settings.switch_user.switch_user")}
value={user?.Name || "-"}
onPress={handleSwitchUser}
disabled={!hasOtherAccounts || isAnyModalOpen}
isFirst
/>
{/* Security Section */}
<TVSectionHeader title={t("home.settings.security.title")} />
<TVSettingsOptionButton
label={t("home.settings.security.inactivity_timeout.title")}
value={inactivityTimeoutLabel}
onPress={() =>
showOptions({
title: t("home.settings.security.inactivity_timeout.title"),
options: inactivityTimeoutOptions,
onSelect: (value) =>
updateSettings({ inactivityTimeout: value }),
})
}
/>
{/* Audio Section */} {/* Audio Section */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} /> <TVSectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsOptionButton <TVSettingsOptionButton
@@ -510,6 +228,7 @@ export default function SettingsTV() {
updateSettings({ audioTranscodeMode: value }), updateSettings({ audioTranscodeMode: value }),
}) })
} }
isFirst
/> />
{/* Subtitles Section */} {/* Subtitles Section */}
@@ -536,10 +255,26 @@ export default function SettingsTV() {
/> />
<TVSettingsStepper <TVSettingsStepper
label={t("home.settings.subtitles.subtitle_size")} label={t("home.settings.subtitles.subtitle_size")}
value={settings.subtitleSize / 100}
onDecrease={() => {
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
onIncrease={() => {
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
formatValue={(v) => `${v.toFixed(1)}x`}
/>
{/* MPV Subtitles Section */}
<TVSectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper
label='Subtitle Scale'
value={settings.mpvSubtitleScale ?? 1.0} value={settings.mpvSubtitleScale ?? 1.0}
onDecrease={() => { onDecrease={() => {
const newValue = Math.max( const newValue = Math.max(
0.1, 0.5,
(settings.mpvSubtitleScale ?? 1.0) - 0.1, (settings.mpvSubtitleScale ?? 1.0) - 0.1,
); );
updateSettings({ updateSettings({
@@ -548,7 +283,7 @@ export default function SettingsTV() {
}} }}
onIncrease={() => { onIncrease={() => {
const newValue = Math.min( const newValue = Math.min(
3.0, 2.0,
(settings.mpvSubtitleScale ?? 1.0) + 0.1, (settings.mpvSubtitleScale ?? 1.0) + 0.1,
); );
updateSettings({ updateSettings({
@@ -647,117 +382,20 @@ export default function SettingsTV() {
"Get your free API key at opensubtitles.com/en/consumers"} "Get your free API key at opensubtitles.com/en/consumers"}
</Text> </Text>
{/* Buffer Settings Section */}
<TVSectionHeader title={t("home.settings.buffer.title")} />
<TVSettingsOptionButton
label={t("home.settings.buffer.cache_mode")}
value={cacheModeLabel}
onPress={() =>
showOptions({
title: t("home.settings.buffer.cache_mode"),
options: cacheModeOptions,
onSelect: (value) => updateSettings({ mpvCacheEnabled: value }),
})
}
/>
{/* Video Output Section */}
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
<TVSettingsOptionButton
label={t("home.settings.vo_driver.vo_mode")}
value={voDriverLabel}
onPress={() =>
showOptions({
title: t("home.settings.vo_driver.vo_mode"),
options: voDriverOptions,
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
})
}
/>
<TVSettingsStepper
label={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10}
onDecrease={() => {
const newValue = Math.max(
5,
(settings.mpvCacheSeconds ?? 10) - 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
120,
(settings.mpvCacheSeconds ?? 10) + 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
formatValue={(v) => `${v}s`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_cache_size")}
value={settings.mpvDemuxerMaxBytes ?? 150}
onDecrease={() => {
const newValue = Math.max(
50,
(settings.mpvDemuxerMaxBytes ?? 150) - 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
500,
(settings.mpvDemuxerMaxBytes ?? 150) + 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_backward_cache")}
value={settings.mpvDemuxerMaxBackBytes ?? 50}
onDecrease={() => {
const newValue = Math.max(
25,
(settings.mpvDemuxerMaxBackBytes ?? 50) - 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
200,
(settings.mpvDemuxerMaxBackBytes ?? 50) + 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
{/* Appearance Section */} {/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} /> <TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton <TVSettingsOptionButton
label={t("home.settings.appearance.display_size")} label={t("home.settings.appearance.text_size")}
value={typographyScaleLabel} value={typographyScaleLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: t("home.settings.appearance.display_size"), title: t("home.settings.appearance.text_size"),
options: typographyScaleOptions, options: typographyScaleOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ tvTypographyScale: value }), updateSettings({ tvTypographyScale: value }),
}) })
} }
/> />
<TVSettingsOptionButton
label={t("home.settings.languages.app_language")}
value={languageLabel}
onPress={() =>
showOptions({
title: t("home.settings.languages.app_language"),
options: languageOptions,
onSelect: (value) =>
updateSettings({ preferedLanguage: value }),
})
}
/>
<TVSettingsToggle <TVSettingsToggle
label={t( label={t(
"home.settings.appearance.merge_next_up_continue_watching", "home.settings.appearance.merge_next_up_continue_watching",
@@ -784,11 +422,6 @@ export default function SettingsTV() {
updateSettings({ showSeriesPosterOnEpisode: value }) updateSettings({ showSeriesPosterOnEpisode: value })
} }
/> />
<TVSettingsToggle
label={t("home.settings.appearance.theme_music")}
value={settings.tvThemeMusicEnabled}
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* User Section */} {/* User Section */}
<TVSectionHeader <TVSectionHeader
@@ -811,37 +444,6 @@ export default function SettingsTV() {
</View> </View>
</ScrollView> </ScrollView>
</View> </View>
{/* PIN Entry Modal */}
<TVPINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
serverUrl={selectedServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<TVPasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
</View> </View>
); );
} }

View File

@@ -85,7 +85,12 @@ export default function Page() {
}, [share, loading]); }, [share, loading]);
return ( return (
<View className='flex-1'> <View
className='flex-1'
style={{
paddingTop: insets.top + 48,
}}
>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> <View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton <FilterButton
id={orderFilterId} id={orderFilterId}
@@ -109,10 +114,7 @@ export default function Page() {
multiple={true} multiple={true}
/> />
</View> </View>
<ScrollView <ScrollView className='pb-4 px-4'>
className='pb-4 px-4'
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
{filteredLogs?.map((log, index) => ( {filteredLogs?.map((log, index) => (
<View className='bg-neutral-900 rounded-xl p-3' key={index}> <View className='bg-neutral-900 rounded-xl p-3' key={index}>

View File

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

View File

@@ -18,7 +18,7 @@ export default function page() {
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true} disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4' className='px-4'
> >
<JellyseerrSettings /> <JellyseerrSettings />
</DisabledSetting> </DisabledSetting>

View File

@@ -18,7 +18,7 @@ export default function page() {
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.useKefinTweaks?.locked === true} disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4' className='px-4'
> >
<KefinTweaksSettings /> <KefinTweaksSettings />
</DisabledSetting> </DisabledSetting>

View File

@@ -27,11 +27,16 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVFilterButton } from "@/components/tv"; import MoviePoster, {
import { TVPosterCard } from "@/components/tv/TVPosterCard"; TV_POSTER_WIDTH,
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; } from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import {
TVFilterButton,
TVFocusablePoster,
TVItemCardText,
} from "@/components/tv";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -55,13 +60,11 @@ const page: React.FC = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string }; const { collectionId } = searchParams as { collectionId: string };
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter(); const router = useRouter();
const { showOptions } = useTVOptionModal(); const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
const { width: screenWidth } = useWindowDimensions(); const { width: screenWidth } = useWindowDimensions();
const [orientation, _setOrientation] = useState( const [orientation, _setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP, ScreenOrientation.Orientation.PORTRAIT_UP,
@@ -150,7 +153,7 @@ const page: React.FC = () => {
// Calculate columns for TV grid // Calculate columns for TV grid
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
if (Platform.isTV) { if (Platform.isTV) {
const itemWidth = posterSizes.poster + TV_ITEM_GAP; const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max( return Math.max(
1, 1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth), Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
@@ -186,7 +189,7 @@ const page: React.FC = () => {
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)), years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: ["Movie", "Series", "Season"], includeItemTypes: ["Movie", "Series"],
}); });
return response.data || null; return response.data || null;
@@ -288,19 +291,23 @@ const page: React.FC = () => {
style={{ style={{
marginRight: TV_ITEM_GAP, marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP, marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}} }}
> >
<TVPosterCard <TVFocusablePoster onPress={handlePress}>
item={item} {item.Type === "Movie" && <MoviePoster item={item} />}
orientation='vertical' {(item.Type === "Series" || item.Type === "Episode") && (
onPress={handlePress} <SeriesPoster item={item} />
onLongPress={() => showItemActions(item)} )}
width={posterSizes.poster} {item.Type !== "Movie" &&
/> item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View> </View>
); );
}, },
[router, showItemActions, posterSizes.poster], [router],
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useMemo } from "react";
@@ -34,13 +33,18 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVFilterButton, TVFocusablePoster } from "@/components/tv"; import MoviePoster, {
import { TVPosterCard } from "@/components/tv/TVPosterCard"; TV_POSTER_WIDTH,
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; } from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import {
TVFilterButton,
TVFocusablePoster,
TVItemCardText,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -66,12 +70,10 @@ import {
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
const TV_ITEM_GAP = 20; const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60; const TV_HORIZONTAL_PADDING = 60;
const _TV_SCALE_PADDING = 20; const _TV_SCALE_PADDING = 20;
const TV_PLAYLIST_SQUARE_SIZE = 180;
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams() as { const searchParams = useLocalSearchParams() as {
@@ -83,7 +85,6 @@ const Page = () => {
const { libraryId } = searchParams; const { libraryId } = searchParams;
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions(); const { width: screenWidth } = useWindowDimensions();
@@ -107,7 +108,6 @@ const Page = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { showOptions } = useTVOptionModal(); const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
// TV Filter queries // TV Filter queries
const { data: tvGenreOptions } = useQuery({ const { data: tvGenreOptions } = useQuery({
@@ -286,8 +286,6 @@ const Page = () => {
itemType = "Video"; itemType = "Video";
} else if (library.CollectionType === "musicvideos") { } else if (library.CollectionType === "musicvideos") {
itemType = "MusicVideo"; itemType = "MusicVideo";
} else if (library.CollectionType === "playlists") {
itemType = "Playlist";
} }
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
@@ -307,9 +305,6 @@ const Page = () => {
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)), years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: itemType ? [itemType] : undefined, includeItemTypes: itemType ? [itemType] : undefined,
...(Platform.isTV && library.CollectionType === "playlists"
? { mediaTypes: ["Video"] }
: {}),
}); });
return response.data || null; return response.data || null;
@@ -406,82 +401,31 @@ const Page = () => {
const renderTVItem = useCallback( const renderTVItem = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
const handlePress = () => { const handlePress = () => {
if (item.Type === "Playlist") {
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: { libraryId: item.Id! },
});
return;
}
const navTarget = getItemNavigation(item, "(libraries)"); const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any); router.push(navTarget as any);
}; };
// Special rendering for Playlist items (square thumbnails)
if (item.Type === "Playlist") {
const playlistImageUrl = getPrimaryImageUrl({
api,
item,
width: TV_PLAYLIST_SQUARE_SIZE * 2,
});
return (
<View
key={item.Id}
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
alignItems: "center",
}}
>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
<View
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
aspectRatio: 1,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
source={playlistImageUrl ? { uri: playlistImageUrl } : null}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, alignItems: "center" }}>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
</View>
</View>
);
}
return ( return (
<TVPosterCard <View
key={item.Id} key={item.Id}
item={item} style={{
orientation='vertical' width: TV_POSTER_WIDTH,
onPress={handlePress} }}
onLongPress={() => showItemActions(item)} >
width={posterSizes.poster} <TVFocusablePoster onPress={handlePress}>
/> {item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
); );
}, },
[router, showItemActions, api, typography], [router],
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);

View File

@@ -42,7 +42,6 @@ import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage"; import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
@@ -70,7 +69,6 @@ export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(search)"; const from = (segments as string[])[2] || "(search)";
@@ -305,9 +303,6 @@ export default function search() {
}, },
hideWhenScrolling: false, hideWhenScrolling: false,
autoFocus: false, autoFocus: false,
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",
}, },
}); });
}, [navigation]); }, [navigation]);
@@ -612,7 +607,6 @@ export default function search() {
loading={loading} loading={loading}
noResults={noResults} noResults={noResults}
onItemPress={handleItemPress} onItemPress={handleItemPress}
onItemLongPress={showItemActions}
searchType={searchType} searchType={searchType}
setSearchType={setSearchType} setSearchType={setSearchType}
showDiscover={!!jellyseerrApi} showDiscover={!!jellyseerrApi}
@@ -941,7 +935,7 @@ export default function search() {
</Text> </Text>
</View> </View>
) : debouncedSearch.length === 0 ? ( ) : debouncedSearch.length === 0 ? (
<View className='mt-2 flex flex-col items-center space-y-2'> <View className='mt-4 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => ( {exampleSearches.map((e) => (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {

View File

@@ -24,12 +24,14 @@ import {
} from "@/components/common/TouchableItemRouter"; } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard"; import MoviePoster, {
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { import {
useDeleteWatchlist, useDeleteWatchlist,
useRemoveFromWatchlist, useRemoveFromWatchlist,
@@ -44,12 +46,35 @@ import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 20; const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60; const TV_HORIZONTAL_PADDING = 60;
type Typography = ReturnType<typeof useScaledTVTypography>;
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => (
<View style={{ marginTop: 12 }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout - 2,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
export default function WatchlistDetailScreen() { export default function WatchlistDetailScreen() {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>(); const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
@@ -178,18 +203,26 @@ export default function WatchlistDetailScreen() {
}; };
return ( return (
<TVPosterCard <View
key={item.Id} key={item.Id}
item={item} style={{
orientation='vertical' width: TV_POSTER_WIDTH,
onPress={handlePress} }}
onLongPress={() => showItemActions(item)} >
hasTVPreferredFocus={index === 0} <TVFocusablePoster
width={posterSizes.poster} onPress={handlePress}
/> hasTVPreferredFocus={index === 0}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
</View>
); );
}, },
[router, showItemActions, posterSizes.poster], [router, typography],
); );
const renderItem = useCallback( const renderItem = useCallback(

View File

@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
@@ -37,9 +36,6 @@ export default function TabLayout() {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
// Handle TV back button - prevent app exit when at root
useTVHomeBackHandler();
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style='light' />
@@ -134,7 +130,7 @@ export default function TabLayout() {
tabBarItemHidden: !Platform.isTV, tabBarItemHidden: !Platform.isTV,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform ? (_e) => require("@/assets/icons/list.png")
: (_e) => ({ sfSymbol: "gearshape.fill" }), : (_e) => ({ sfSymbol: "gearshape.fill" }),
}} }}
/> />

View File

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

View File

@@ -1,6 +1,6 @@
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
Animated, Animated,
Easing, Easing,
@@ -11,17 +11,13 @@ import {
} from "react-native"; } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVOptionCard } from "@/components/tv"; import { TVOptionCard } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal"; import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
import { scaleSize } from "@/utils/scaleSize";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
export default function TVOptionModal() { export default function TVOptionModal() {
const router = useRouter(); const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom); const modalState = useAtomValue(tvOptionModalAtom);
const typography = useScaledTVTypography();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null); const firstCardRef = useRef<View>(null);
@@ -80,25 +76,12 @@ export default function TVOptionModal() {
router.back(); router.back();
}; };
const handleClose = useCallback(() => {
store.set(tvOptionModalAtom, null);
router.back();
}, [router]);
// Intercept back/menu press to close the modal instead of the player
useTVBackPress(() => {
handleClose();
return true;
}, [handleClose]);
// If no modal state, just go back (shouldn't happen in normal usage) // If no modal state, just go back (shouldn't happen in normal usage)
if (!modalState) { if (!modalState) {
return null; return null;
} }
const { title, options } = modalState; const { title, options, cardWidth = 160, cardHeight = 75 } = modalState;
const scaledCardWidth = scaleSize(160);
const scaledCardHeight = scaleSize(75);
return ( return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}> <Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
@@ -117,9 +100,7 @@ export default function TVOptionModal() {
trapFocusRight trapFocusRight
style={styles.content} style={styles.content}
> >
<Text style={[styles.title, { fontSize: typography.callout }]}> <Text style={styles.title}>{title}</Text>
{title}
</Text>
{isReady && ( {isReady && (
<ScrollView <ScrollView
horizontal horizontal
@@ -138,8 +119,8 @@ export default function TVOptionModal() {
selected={option.selected} selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex} hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => handleSelect(option.value)} onPress={() => handleSelect(option.value)}
width={scaledCardWidth} width={cardWidth}
height={scaledCardHeight} height={cardHeight}
/> />
))} ))}
</ScrollView> </ScrollView>
@@ -161,20 +142,21 @@ const styles = StyleSheet.create({
width: "100%", width: "100%",
}, },
blurContainer: { blurContainer: {
borderTopLeftRadius: scaleSize(24), borderTopLeftRadius: 24,
borderTopRightRadius: scaleSize(24), borderTopRightRadius: 24,
overflow: "hidden", overflow: "hidden",
}, },
content: { content: {
paddingTop: scaleSize(24), paddingTop: 24,
paddingBottom: scaleSize(50), paddingBottom: 50,
overflow: "visible", overflow: "visible",
}, },
title: { title: {
fontSize: 18,
fontWeight: "500", fontWeight: "500",
color: "rgba(255,255,255,0.6)", color: "rgba(255,255,255,0.6)",
marginBottom: scaleSize(16), marginBottom: 16,
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: 1, letterSpacing: 1,
}, },
@@ -182,8 +164,8 @@ const styles = StyleSheet.create({
overflow: "visible", overflow: "visible",
}, },
scrollContent: { scrollContent: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
paddingVertical: scaleSize(20), paddingVertical: 20,
gap: scaleSize(12), gap: 12,
}, },
}); });

View File

@@ -22,17 +22,14 @@ import {
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVTabButton, useTVFocusAnimation } from "@/components/tv"; import { TVTabButton, useTVFocusAnimation } from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types"; import type { Track } from "@/components/video-player/controls/types";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { import {
type SubtitleSearchResult, type SubtitleSearchResult,
useRemoteSubtitles, useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles"; } from "@/hooks/useRemoteSubtitles";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal"; import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api"; import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
import { scaleSize } from "@/utils/scaleSize";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
type TabType = "tracks" | "download" | "settings"; type TabType = "tracks" | "download" | "settings";
@@ -75,10 +72,10 @@ const TVTrackCard = React.forwardRef<
<Text <Text
style={[ style={[
styles.trackCardText, styles.trackCardText,
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(16) }, { color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" }, (focused || selected) && { fontWeight: "600" },
]} ]}
numberOfLines={3} numberOfLines={2}
> >
{label} {label}
</Text> </Text>
@@ -86,10 +83,7 @@ const TVTrackCard = React.forwardRef<
<Text <Text
style={[ style={[
styles.trackCardSublabel, styles.trackCardSublabel,
{ { color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
fontSize: scaleSize(12),
},
]} ]}
numberOfLines={1} numberOfLines={1}
> >
@@ -100,7 +94,7 @@ const TVTrackCard = React.forwardRef<
<View style={styles.checkmark}> <View style={styles.checkmark}>
<Ionicons <Ionicons
name='checkmark' name='checkmark'
size={scaleSize(16)} size={16}
color='rgba(255,255,255,0.8)' color='rgba(255,255,255,0.8)'
/> />
</View> </View>
@@ -148,7 +142,7 @@ const LanguageCard = React.forwardRef<
<Text <Text
style={[ style={[
styles.languageCardText, styles.languageCardText,
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(15) }, { color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" }, (focused || selected) && { fontWeight: "600" },
]} ]}
numberOfLines={1} numberOfLines={1}
@@ -158,10 +152,7 @@ const LanguageCard = React.forwardRef<
<Text <Text
style={[ style={[
styles.languageCardCode, styles.languageCardCode,
{ { color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
fontSize: scaleSize(11),
},
]} ]}
> >
{code.toUpperCase()} {code.toUpperCase()}
@@ -170,7 +161,7 @@ const LanguageCard = React.forwardRef<
<View style={styles.checkmark}> <View style={styles.checkmark}>
<Ionicons <Ionicons
name='checkmark' name='checkmark'
size={scaleSize(16)} size={16}
color='rgba(255,255,255,0.8)' color='rgba(255,255,255,0.8)'
/> />
</View> </View>
@@ -228,10 +219,7 @@ const SubtitleResultCard = React.forwardRef<
<Text <Text
style={[ style={[
styles.providerText, styles.providerText,
{ { color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)" },
color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)",
fontSize: scaleSize(11),
},
]} ]}
> >
{result.providerName} {result.providerName}
@@ -240,10 +228,7 @@ const SubtitleResultCard = React.forwardRef<
{/* Name */} {/* Name */}
<Text <Text
style={[ style={[styles.resultName, { color: focused ? "#000" : "#fff" }]}
styles.resultName,
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(14) },
]}
numberOfLines={2} numberOfLines={2}
> >
{result.name} {result.name}
@@ -255,10 +240,7 @@ const SubtitleResultCard = React.forwardRef<
<Text <Text
style={[ style={[
styles.resultMetaText, styles.resultMetaText,
{ { color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
fontSize: scaleSize(12),
},
]} ]}
> >
{result.format?.toUpperCase()} {result.format?.toUpperCase()}
@@ -270,7 +252,7 @@ const SubtitleResultCard = React.forwardRef<
<View style={styles.ratingContainer}> <View style={styles.ratingContainer}>
<Ionicons <Ionicons
name='star' name='star'
size={scaleSize(12)} size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"} color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/> />
<Text <Text
@@ -280,7 +262,6 @@ const SubtitleResultCard = React.forwardRef<
color: focused color: focused
? "rgba(0,0,0,0.6)" ? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)", : "rgba(255,255,255,0.5)",
fontSize: scaleSize(12),
}, },
]} ]}
> >
@@ -294,7 +275,7 @@ const SubtitleResultCard = React.forwardRef<
<View style={styles.downloadCountContainer}> <View style={styles.downloadCountContainer}>
<Ionicons <Ionicons
name='download-outline' name='download-outline'
size={scaleSize(12)} size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"} color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/> />
<Text <Text
@@ -304,7 +285,6 @@ const SubtitleResultCard = React.forwardRef<
color: focused color: focused
? "rgba(0,0,0,0.6)" ? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)", : "rgba(255,255,255,0.5)",
fontSize: scaleSize(12),
}, },
]} ]}
> >
@@ -327,9 +307,7 @@ const SubtitleResultCard = React.forwardRef<
}, },
]} ]}
> >
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}> <Text style={styles.flagText}>Hash Match</Text>
Hash Match
</Text>
</View> </View>
)} )}
{result.hearingImpaired && ( {result.hearingImpaired && (
@@ -345,7 +323,7 @@ const SubtitleResultCard = React.forwardRef<
> >
<Ionicons <Ionicons
name='ear-outline' name='ear-outline'
size={scaleSize(12)} size={12}
color={focused ? "#000" : "#fff"} color={focused ? "#000" : "#fff"}
/> />
</View> </View>
@@ -361,9 +339,7 @@ const SubtitleResultCard = React.forwardRef<
}, },
]} ]}
> >
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}> <Text style={styles.flagText}>AI</Text>
AI
</Text>
</View> </View>
)} )}
</View> </View>
@@ -413,7 +389,7 @@ const TVStepperButton: React.FC<{
> >
<Ionicons <Ionicons
name={icon} name={icon}
size={scaleSize(28)} size={28}
color={focused ? "#000" : disabled ? "rgba(255,255,255,0.4)" : "#fff"} color={focused ? "#000" : disabled ? "rgba(255,255,255,0.4)" : "#fff"}
/> />
</Animated.View> </Animated.View>
@@ -509,7 +485,7 @@ const TVAlignmentCard: React.FC<{
<Text <Text
style={[ style={[
styles.alignmentCardText, styles.alignmentCardText,
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(15) }, { color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" }, (focused || selected) && { fontWeight: "600" },
]} ]}
> >
@@ -519,7 +495,7 @@ const TVAlignmentCard: React.FC<{
<View style={styles.alignmentCheckmark}> <View style={styles.alignmentCheckmark}>
<Ionicons <Ionicons
name='checkmark' name='checkmark'
size={scaleSize(14)} size={14}
color='rgba(255,255,255,0.8)' color='rgba(255,255,255,0.8)'
/> />
</View> </View>
@@ -534,7 +510,6 @@ export default function TVSubtitleModal() {
const { t } = useTranslation(); const { t } = useTranslation();
const modalState = useAtomValue(tvSubtitleModalAtom); const modalState = useAtomValue(tvSubtitleModalAtom);
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const typography = useScaledTVTypography();
const [activeTab, setActiveTab] = useState<TabType>("tracks"); const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng"); const [selectedLanguage, setSelectedLanguage] = useState("eng");
@@ -629,12 +604,6 @@ export default function TVSubtitleModal() {
router.back(); router.back();
}, [router]); }, [router]);
// Intercept back/menu press to close the modal instead of the player
useTVBackPress(() => {
handleClose();
return true;
}, [handleClose]);
const handleLanguageSelect = useCallback( const handleLanguageSelect = useCallback(
(code: string) => { (code: string) => {
setSelectedLanguage(code); setSelectedLanguage(code);
@@ -690,30 +659,8 @@ export default function TVSubtitleModal() {
// Do NOT close modal - user can see and select the new track // Do NOT close modal - user can see and select the new track
} else if (downloadResult.type === "local" && downloadResult.path) { } else if (downloadResult.type === "local" && downloadResult.path) {
// Notify parent that a local subtitle was downloaded
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path); modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
handleClose(); // Only close for local downloads
// Check if component is still mounted after callback
if (!isMountedRef.current) return;
// Refresh tracks to include the newly downloaded subtitle
if (modalState?.refreshSubtitleTracks) {
const newTracks = await modalState.refreshSubtitleTracks();
// Check if component is still mounted after fetching tracks
if (!isMountedRef.current) return;
// Update atom with new tracks
store.set(tvSubtitleModalAtom, {
...modalState,
subtitleTracks: newTracks,
});
// Switch to tracks tab to show the new subtitle
setActiveTab("tracks");
} else {
// No refreshSubtitleTracks available (e.g., from player), just close
handleClose();
}
} }
} catch (error) { } catch (error) {
console.error("Failed to download subtitle:", error); console.error("Failed to download subtitle:", error);
@@ -738,17 +685,13 @@ export default function TVSubtitleModal() {
value: -1, value: -1,
selected: currentSubtitleIndex === -1, selected: currentSubtitleIndex === -1,
setTrack: () => modalState?.onDisableSubtitles?.(), setTrack: () => modalState?.onDisableSubtitles?.(),
isLocal: false,
}; };
const options = subtitleTracks.map((track: Track) => ({ const options = subtitleTracks.map((track: Track) => ({
label: track.name, label: track.name,
sublabel: track.isLocal sublabel: undefined as string | undefined,
? t("player.downloaded") || "Downloaded"
: (undefined as string | undefined),
value: track.index, value: track.index,
selected: track.index === currentSubtitleIndex, selected: track.index === currentSubtitleIndex,
setTrack: track.setTrack, setTrack: track.setTrack,
isLocal: track.isLocal ?? false,
})); }));
return [noneOption, ...options]; return [noneOption, ...options];
}, [subtitleTracks, currentSubtitleIndex, t, modalState]); }, [subtitleTracks, currentSubtitleIndex, t, modalState]);
@@ -776,7 +719,7 @@ export default function TVSubtitleModal() {
> >
{/* Header with tabs */} {/* Header with tabs */}
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.title, { fontSize: typography.heading }]}> <Text style={styles.title}>
{t("item_card.subtitles.label") || "Subtitles"} {t("item_card.subtitles.label") || "Subtitles"}
</Text> </Text>
@@ -833,9 +776,7 @@ export default function TVSubtitleModal() {
<> <>
{/* Language Selector */} {/* Language Selector */}
<View style={styles.section}> <View style={styles.section}>
<Text <Text style={styles.sectionTitle}>
style={[styles.sectionTitle, { fontSize: scaleSize(14) }]}
>
{t("player.language") || "Language"} {t("player.language") || "Language"}
</Text> </Text>
<ScrollView <ScrollView
@@ -862,9 +803,7 @@ export default function TVSubtitleModal() {
{/* Results Section */} {/* Results Section */}
<View style={styles.section}> <View style={styles.section}>
<Text <Text style={styles.sectionTitle}>
style={[styles.sectionTitle, { fontSize: scaleSize(14) }]}
>
{t("player.results") || "Results"} {t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`} {searchResults && ` (${searchResults.length})`}
</Text> </Text>
@@ -881,17 +820,13 @@ export default function TVSubtitleModal() {
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Ionicons <Ionicons
name='alert-circle-outline' name='alert-circle-outline'
size={scaleSize(32)} size={32}
color='rgba(255,100,100,0.8)' color='rgba(255,100,100,0.8)'
/> />
<Text <Text style={styles.errorText}>
style={[styles.errorText, { fontSize: scaleSize(16) }]}
>
{t("player.search_failed") || "Search failed"} {t("player.search_failed") || "Search failed"}
</Text> </Text>
<Text <Text style={styles.errorHint}>
style={[styles.errorHint, { fontSize: scaleSize(13) }]}
>
{!hasOpenSubtitlesApiKey {!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") || ? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server" "No subtitle provider configured on server"
@@ -908,15 +843,10 @@ export default function TVSubtitleModal() {
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Ionicons <Ionicons
name='document-text-outline' name='document-text-outline'
size={scaleSize(32)} size={32}
color='rgba(255,255,255,0.4)' color='rgba(255,255,255,0.4)'
/> />
<Text <Text style={styles.emptyText}>
style={[
styles.emptyText,
{ fontSize: scaleSize(14) },
]}
>
{t("player.no_subtitles_found") || {t("player.no_subtitles_found") ||
"No subtitles found"} "No subtitles found"}
</Text> </Text>
@@ -951,15 +881,10 @@ export default function TVSubtitleModal() {
<View style={styles.apiKeyHint}> <View style={styles.apiKeyHint}>
<Ionicons <Ionicons
name='information-circle-outline' name='information-circle-outline'
size={scaleSize(16)} size={16}
color='rgba(255,255,255,0.4)' color='rgba(255,255,255,0.4)'
/> />
<Text <Text style={styles.apiKeyHintText}>
style={[
styles.apiKeyHintText,
{ fontSize: scaleSize(12) },
]}
>
{t("player.add_opensubtitles_key_hint") || {t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"} "Add OpenSubtitles API key in settings for client-side fallback"}
</Text> </Text>
@@ -980,8 +905,8 @@ export default function TVSubtitleModal() {
<View style={styles.settingRow}> <View style={styles.settingRow}>
<TVStepperControl <TVStepperControl
value={settings.mpvSubtitleScale ?? 1.0} value={settings.mpvSubtitleScale ?? 1.0}
min={0.1} min={0.5}
max={3.0} max={2.0}
step={0.1} step={0.1}
formatValue={(v) => `${v.toFixed(1)}x`} formatValue={(v) => `${v.toFixed(1)}x`}
onChange={(newValue) => { onChange={(newValue) => {
@@ -991,12 +916,7 @@ export default function TVSubtitleModal() {
}} }}
hasTVPreferredFocus={true} hasTVPreferredFocus={true}
/> />
<Text <Text style={styles.settingLabel}>
style={[
styles.settingLabel,
{ fontSize: typography.callout },
]}
>
{t("home.settings.subtitles.mpv_subtitle_scale") || {t("home.settings.subtitles.mpv_subtitle_scale") ||
"Subtitle Scale"} "Subtitle Scale"}
</Text> </Text>
@@ -1014,12 +934,7 @@ export default function TVSubtitleModal() {
updateSettings({ mpvSubtitleMarginY: newValue }); updateSettings({ mpvSubtitleMarginY: newValue });
}} }}
/> />
<Text <Text style={styles.settingLabel}>
style={[
styles.settingLabel,
{ fontSize: typography.callout },
]}
>
{t("home.settings.subtitles.mpv_subtitle_margin_y") || {t("home.settings.subtitles.mpv_subtitle_margin_y") ||
"Vertical Margin"} "Vertical Margin"}
</Text> </Text>
@@ -1043,12 +958,7 @@ export default function TVSubtitleModal() {
/> />
))} ))}
</View> </View>
<Text <Text style={styles.settingLabel}>
style={[
styles.settingLabel,
{ fontSize: typography.callout },
]}
>
{t("home.settings.subtitles.mpv_subtitle_align_x") || {t("home.settings.subtitles.mpv_subtitle_align_x") ||
"Horizontal Align"} "Horizontal Align"}
</Text> </Text>
@@ -1072,12 +982,7 @@ export default function TVSubtitleModal() {
/> />
))} ))}
</View> </View>
<Text <Text style={styles.settingLabel}>
style={[
styles.settingLabel,
{ fontSize: typography.callout },
]}
>
{t("home.settings.subtitles.mpv_subtitle_align_y") || {t("home.settings.subtitles.mpv_subtitle_align_y") ||
"Vertical Align"} "Vertical Align"}
</Text> </Text>
@@ -1102,201 +1007,218 @@ const styles = StyleSheet.create({
maxHeight: "70%", maxHeight: "70%",
}, },
blurContainer: { blurContainer: {
borderTopLeftRadius: scaleSize(24), borderTopLeftRadius: 24,
borderTopRightRadius: scaleSize(24), borderTopRightRadius: 24,
overflow: "hidden", overflow: "hidden",
}, },
content: { content: {
paddingTop: scaleSize(24), paddingTop: 24,
paddingBottom: scaleSize(48), paddingBottom: 48,
}, },
header: { header: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
marginBottom: scaleSize(20), marginBottom: 20,
}, },
title: { title: {
fontSize: 24,
fontWeight: "600", fontWeight: "600",
color: "#fff", color: "#fff",
marginBottom: scaleSize(16), marginBottom: 16,
}, },
tabRow: { tabRow: {
flexDirection: "row", flexDirection: "row",
gap: scaleSize(24), gap: 24,
}, },
section: { section: {
marginBottom: scaleSize(20), marginBottom: 20,
}, },
sectionTitle: { sectionTitle: {
fontSize: 14,
fontWeight: "500", fontWeight: "500",
color: "rgba(255,255,255,0.5)", color: "rgba(255,255,255,0.5)",
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: 1, letterSpacing: 1,
marginBottom: scaleSize(12), marginBottom: 12,
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
}, },
tracksScroll: { tracksScroll: {
overflow: "visible", overflow: "visible",
}, },
tracksScrollContent: { tracksScrollContent: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
paddingVertical: scaleSize(8), paddingVertical: 8,
gap: scaleSize(12), gap: 12,
}, },
trackCard: { trackCard: {
width: scaleSize(180), width: 180,
height: scaleSize(80), height: 80,
borderRadius: scaleSize(14), borderRadius: 14,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
paddingHorizontal: scaleSize(12), paddingHorizontal: 12,
}, },
trackCardText: { trackCardText: {
fontSize: 16,
textAlign: "center", textAlign: "center",
}, },
trackCardSublabel: { trackCardSublabel: {
marginTop: scaleSize(2), fontSize: 12,
marginTop: 2,
}, },
checkmark: { checkmark: {
position: "absolute", position: "absolute",
top: scaleSize(8), top: 8,
right: scaleSize(8), right: 8,
}, },
languageScroll: { languageScroll: {
overflow: "visible", overflow: "visible",
}, },
languageScrollContent: { languageScrollContent: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
paddingVertical: scaleSize(8), paddingVertical: 8,
gap: scaleSize(10), gap: 10,
}, },
languageCard: { languageCard: {
width: scaleSize(120), width: 120,
height: scaleSize(60), height: 60,
borderRadius: scaleSize(12), borderRadius: 12,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
paddingHorizontal: scaleSize(12), paddingHorizontal: 12,
}, },
languageCardText: { languageCardText: {
fontSize: 15,
fontWeight: "500", fontWeight: "500",
}, },
languageCardCode: { languageCardCode: {
marginTop: scaleSize(2), fontSize: 11,
marginTop: 2,
}, },
resultsScroll: { resultsScroll: {
overflow: "visible", overflow: "visible",
}, },
resultsScrollContent: { resultsScrollContent: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
paddingVertical: scaleSize(8), paddingVertical: 8,
gap: scaleSize(12), gap: 12,
}, },
resultCard: { resultCard: {
width: scaleSize(220), width: 220,
height: scaleSize(130), height: 130,
borderRadius: scaleSize(14), borderRadius: 14,
padding: scaleSize(14), padding: 14,
borderWidth: 1, borderWidth: 1,
overflow: "hidden", overflow: "hidden",
}, },
providerBadge: { providerBadge: {
alignSelf: "flex-start", alignSelf: "flex-start",
paddingHorizontal: scaleSize(8), paddingHorizontal: 8,
paddingVertical: scaleSize(3), paddingVertical: 3,
borderRadius: scaleSize(6), borderRadius: 6,
marginBottom: scaleSize(8), marginBottom: 8,
}, },
providerText: { providerText: {
fontSize: 11,
fontWeight: "600", fontWeight: "600",
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: 0.5, letterSpacing: 0.5,
}, },
resultName: { resultName: {
fontSize: 14,
fontWeight: "500", fontWeight: "500",
marginBottom: scaleSize(8), marginBottom: 8,
lineHeight: scaleSize(18), lineHeight: 18,
}, },
resultMeta: { resultMeta: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: scaleSize(12), gap: 12,
marginBottom: scaleSize(8), marginBottom: 8,
},
resultMetaText: {
fontSize: 12,
}, },
resultMetaText: {},
ratingContainer: { ratingContainer: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: scaleSize(3), gap: 3,
}, },
downloadCountContainer: { downloadCountContainer: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: scaleSize(3), gap: 3,
}, },
flagsContainer: { flagsContainer: {
flexDirection: "row", flexDirection: "row",
gap: scaleSize(6), gap: 6,
flexWrap: "wrap", flexWrap: "wrap",
}, },
flag: { flag: {
paddingHorizontal: scaleSize(6), paddingHorizontal: 6,
paddingVertical: scaleSize(2), paddingVertical: 2,
borderRadius: scaleSize(4), borderRadius: 4,
}, },
flagText: { flagText: {
fontSize: 10,
fontWeight: "600", fontWeight: "600",
color: "#fff", color: "#fff",
}, },
downloadingOverlay: { downloadingOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)", backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: scaleSize(14), borderRadius: 14,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
loadingContainer: { loadingContainer: {
paddingVertical: scaleSize(20), paddingVertical: 20,
alignItems: "center", alignItems: "center",
}, },
errorContainer: { errorContainer: {
paddingVertical: scaleSize(40), paddingVertical: 40,
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
alignItems: "center", alignItems: "center",
}, },
errorText: { errorText: {
color: "rgba(255,100,100,0.9)", color: "rgba(255,100,100,0.9)",
marginTop: scaleSize(8), marginTop: 8,
fontSize: 16,
fontWeight: "500", fontWeight: "500",
}, },
errorHint: { errorHint: {
color: "rgba(255,255,255,0.5)", color: "rgba(255,255,255,0.5)",
marginTop: scaleSize(4), marginTop: 4,
fontSize: 13,
textAlign: "center", textAlign: "center",
}, },
emptyContainer: { emptyContainer: {
paddingVertical: scaleSize(40), paddingVertical: 40,
alignItems: "center", alignItems: "center",
}, },
emptyText: { emptyText: {
color: "rgba(255,255,255,0.5)", color: "rgba(255,255,255,0.5)",
marginTop: scaleSize(8), marginTop: 8,
fontSize: 14,
}, },
apiKeyHint: { apiKeyHint: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: scaleSize(8), gap: 8,
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
paddingTop: scaleSize(8), paddingTop: 8,
},
apiKeyHintText: {
color: "rgba(255,255,255,0.4)",
fontSize: 12,
}, },
apiKeyHintText: {},
// Settings tab styles // Settings tab styles
settingsScroll: { settingsScroll: {
maxHeight: scaleSize(300), maxHeight: 300,
}, },
settingsScrollContent: { settingsScrollContent: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
paddingVertical: scaleSize(8), paddingVertical: 8,
gap: scaleSize(24), gap: 24,
}, },
settingRow: { settingRow: {
flexDirection: "row", flexDirection: "row",
@@ -1304,47 +1226,49 @@ const styles = StyleSheet.create({
justifyContent: "space-between", justifyContent: "space-between",
}, },
settingLabel: { settingLabel: {
fontSize: 18,
fontWeight: "500", fontWeight: "500",
color: "#fff", color: "#fff",
}, },
sizeControlContainer: { sizeControlContainer: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: scaleSize(16), gap: 16,
}, },
stepperButton: { stepperButton: {
width: scaleSize(56), width: 56,
height: scaleSize(56), height: 56,
borderRadius: scaleSize(14), borderRadius: 14,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
sizeValueContainer: { sizeValueContainer: {
width: scaleSize(80), width: 80,
alignItems: "center", alignItems: "center",
}, },
sizeValueText: { sizeValueText: {
fontSize: 24,
fontWeight: "600", fontWeight: "600",
color: "#fff", color: "#fff",
fontSize: scaleSize(24),
}, },
alignmentRow: { alignmentRow: {
flexDirection: "row", flexDirection: "row",
gap: scaleSize(10), gap: 10,
}, },
alignmentCard: { alignmentCard: {
paddingHorizontal: scaleSize(20), paddingHorizontal: 20,
paddingVertical: scaleSize(14), paddingVertical: 14,
borderRadius: scaleSize(12), borderRadius: 12,
minWidth: scaleSize(90), minWidth: 90,
alignItems: "center", alignItems: "center",
}, },
alignmentCardText: { alignmentCardText: {
fontSize: 15,
textTransform: "capitalize", textTransform: "capitalize",
}, },
alignmentCheckmark: { alignmentCheckmark: {
position: "absolute", position: "absolute",
top: scaleSize(6), top: 6,
right: scaleSize(6), right: 6,
}, },
}); });

View File

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

View File

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

View File

@@ -1,33 +0,0 @@
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useEffect } from "react";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
export default function TopShelfItemRedirect() {
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const { id, type } = useLocalSearchParams<{
id?: string;
type?: string;
}>();
useEffect(() => {
if (!rootNavigationState?.key) {
return;
}
if (!id) {
router.replace("/(auth)/(tabs)/(home)");
return;
}
if (type === "Series") {
router.replace(`/(auth)/(tabs)/(home)/series/${id}`);
return;
}
router.replace(`/(auth)/(tabs)/(home)/items/page?id=${id}`);
}, [id, rootNavigationState?.key, router, type]);
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
}

View File

@@ -1,32 +0,0 @@
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useEffect } from "react";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
export default function TopShelfPlayRedirect() {
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const { id } = useLocalSearchParams<{
id?: string;
}>();
useEffect(() => {
if (!rootNavigationState?.key) {
return;
}
if (!id) {
router.replace("/(auth)/(tabs)/(home)");
return;
}
const queryParams = new URLSearchParams({
itemId: id,
offline: "false",
});
router.replace(`/player/direct-player?${queryParams.toString()}`);
}, [id, rootNavigationState?.key, router]);
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
}

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -11,7 +11,7 @@ Number.prototype.bytesToReadable = function (decimals = 2) {
const bytes = this.valueOf(); const bytes = this.valueOf();
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return "0 Bytes";
const k = 1000; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];

View File

@@ -8,8 +8,6 @@
"!android", "!android",
"!Streamyfin.app", "!Streamyfin.app",
"!utils/jellyseerr", "!utils/jellyseerr",
"!expo-env.d.ts",
"!modules/**/android/build",
"!.expo", "!.expo",
"!docs/jellyfin-openapi-stable.json" "!docs/jellyfin-openapi-stable.json"
] ]

View File

@@ -1,87 +0,0 @@
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
index 47a671928338ae7fb4f85532d9fd1ed2d594f823..e4eecb7d5f9d3c3afc8a090fb953010e4e1b8a08 100644
--- a/ios/RNSScreenStack.mm
+++ b/ios/RNSScreenStack.mm
@@ -34,6 +34,11 @@
#import "integrations/RNSDismissibleModalProtocol.h"
#import "utils/UINavigationBar+RNSUtility.h"
+#if TARGET_OS_TV
+#import <React/RCTTVNavigationEventNotification.h>
+#import <React/RCTTVRemoteHandler.h>
+#endif // TARGET_OS_TV
+
#ifdef RNS_GAMMA_ENABLED
#import "RNSFrameCorrectionProvider.h"
#import "Swift-Bridging.h"
@@ -43,6 +48,12 @@
namespace react = facebook::react;
#endif // RCT_NEW_ARCH_ENABLED
+#if TARGET_OS_TV
+@interface RNSNavigationController ()
+@property (nonatomic, strong) UITapGestureRecognizer *rnscreens_menuGestureRecognizer;
+@end
+#endif // TARGET_OS_TV
+
@interface RNSScreenStackView () <
UINavigationControllerDelegate,
UIAdaptivePresentationControllerDelegate,
@@ -61,6 +72,57 @@
@end
@implementation RNSNavigationController
+
+#if TARGET_OS_TV
+- (void)viewDidLoad
+{
+ [super viewDidLoad];
+
+ self.rnscreens_menuGestureRecognizer =
+ [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rnscreens_menuPressed:)];
+ self.rnscreens_menuGestureRecognizer.allowedPressTypes = @[ @(UIPressTypeMenu) ];
+
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(rnscreens_enableMenuGesture)
+ name:RCTTVEnableMenuKeyNotification
+ object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(rnscreens_disableMenuGesture)
+ name:RCTTVDisableMenuKeyNotification
+ object:nil];
+
+ if ([RCTTVRemoteHandler useMenuKey]) {
+ [self rnscreens_enableMenuGesture];
+ }
+}
+
+- (void)dealloc
+{
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+- (void)rnscreens_enableMenuGesture
+{
+ if (![self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
+ [self.view addGestureRecognizer:self.rnscreens_menuGestureRecognizer];
+ }
+}
+
+- (void)rnscreens_disableMenuGesture
+{
+ if ([self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
+ [self.view removeGestureRecognizer:self.rnscreens_menuGestureRecognizer];
+ }
+}
+
+- (void)rnscreens_menuPressed:(UIGestureRecognizer *)recognizer
+{
+ [[NSNotificationCenter defaultCenter] postNavigationPressEventWithType:RCTTVRemoteEventMenu
+ keyAction:recognizer.eventKeyAction
+ tag:nil
+ target:nil];
+}
+#endif // TARGET_OS_TV
#if !TARGET_OS_TV
- (UIViewController *)childViewControllerForStatusBarStyle

View File

@@ -1,17 +0,0 @@
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
--- a/react-native-udp.podspec
+++ b/react-native-udp.podspec
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
s.homepage = package_json["homepage"]
s.license = package_json["license"]
s.author = { package_json["author"] => package_json["author"] }
- s.platform = :ios, "7.0"
+ s.ios.deployment_target = "7.0"
+ s.tvos.deployment_target = "15.1"
s.source = { :git => package_json["repository"]["url"], :tag => "v#{s.version}" }
s.source_files = 'ios/**/*.{h,m}'
s.dependency 'React-Core'

945
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,6 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { scaleSize } from "@/utils/scaleSize";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
const getColorClasses = ( const getColorClasses = (
@@ -133,29 +132,19 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<Animated.View <Animated.View
style={{ style={{
transform: [{ scale }], transform: [{ scale }],
shadowColor: "#ffffff", shadowColor: color === "black" ? "#ffffff" : "#a855f7",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0, shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? scaleSize(10) : 0, shadowRadius: focused ? 10 : 0,
elevation: focused ? 12 : 0, // Android glow elevation: focused ? 12 : 0, // Android glow
}} }}
> >
<View <View
style={{ className={`rounded-2xl py-5 items-center justify-center
borderRadius: scaleSize(16), ${colorClasses}
paddingVertical: scaleSize(14), ${className}`}
alignItems: "center",
justifyContent: "center",
}}
className={`${colorClasses} ${className}`}
> >
<Text <Text className={`${textColorClass} text-xl font-bold`}>
style={{
fontSize: scaleSize(20),
fontWeight: "bold",
}}
className={textColorClass}
>
{children} {children}
</Text> </Text>
</View> </View>

View File

@@ -0,0 +1,188 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator";
export const TV_LANDSCAPE_WIDTH = 400;
type ContinueWatchingPosterProps = {
item: BaseItemDto;
useEpisodePoster?: boolean;
size?: "small" | "normal";
showPlayButton?: boolean;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
useEpisodePoster = false,
// TV version uses fixed width, size prop kept for API compatibility
size: _size = "normal",
showPlayButton = false,
}) => {
const api = useAtomValue(apiAtom);
const url = useMemo(() => {
if (!api) {
return;
}
if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Movie") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}, [api, item, useEpisodePoster]);
const progress = useMemo(() => {
if (item.Type === "Program") {
if (!item.StartDate || !item.EndDate) {
return 0;
}
const startDate = new Date(item.StartDate);
const endDate = new Date(item.EndDate);
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
if (total <= 0) {
return 0;
}
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
const isWatched = item.UserData?.Played === true;
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
if (!url) {
return (
<View
style={{
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
}}
/>
);
}
if (useGlass) {
return (
<View style={{ position: "relative" }}>
<GlassPosterView
imageUrl={url}
aspectRatio={16 / 9}
cornerRadius={24}
progress={progress}
showWatchedIndicator={isWatched}
isFocused={false}
width={TV_LANDSCAPE_WIDTH}
style={{ width: TV_LANDSCAPE_WIDTH }}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
);
}
// Fallback for older tvOS versions
return (
<View
style={{
position: "relative",
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
width: "100%",
height: "100%",
}}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
<WatchedIndicator item={item} />
<ProgressBar item={item} />
</View>
);
};
export default ContinueWatchingPoster;

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import type {
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { File } from "expo-file-system";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { import React, {
@@ -18,14 +17,14 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Dimensions, ScrollView, View } from "react-native"; import { Dimensions, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage"; import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { GenreTags } from "@/components/GenreTags"; import { GenreTags } from "@/components/GenreTags";
import { TVEpisodeList } from "@/components/series/TVEpisodeList"; import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
import { import {
TVBackdrop, TVBackdrop,
TVButton, TVButton,
@@ -34,7 +33,6 @@ import {
TVFavoriteButton, TVFavoriteButton,
TVMetadataBadges, TVMetadataBadges,
TVOptionButton, TVOptionButton,
TVPlayedButton,
TVProgressBar, TVProgressBar,
TVRefreshButton, TVRefreshButton,
TVSeriesNavigation, TVSeriesNavigation,
@@ -45,18 +43,15 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -83,15 +78,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const _itemColors = useImageColorsReturn({ item }); const _itemColors = useImageColorsReturn({ item });
// Auto-play theme music (handles fade in/out and cleanup)
useTVThemeMusic(item?.Id);
// State for first episode card ref (used for focus guide) // State for first episode card ref (used for focus guide)
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null); const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
@@ -121,22 +112,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
SelectedOptions | undefined SelectedOptions | undefined
>(undefined); >(undefined);
// Enable language preference application for TV
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const { const {
defaultAudioIndex, defaultAudioIndex,
defaultBitrate, defaultBitrate,
defaultMediaSource, defaultMediaSource,
defaultSubtitleIndex, defaultSubtitleIndex,
} = useDefaultPlaySettings( } = useDefaultPlaySettings(itemWithSources ?? item, settings);
itemWithSources ?? item,
settings,
playSettingsOptions,
);
const logoUrl = useMemo( const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null), () => (item ? getLogoImageUrlById({ api, item }) : null),
@@ -158,59 +139,21 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
defaultMediaSource, defaultMediaSource,
]); ]);
const navigateToPlayer = useCallback(
(playbackPosition: string) => {
if (!item || !selectedOptions) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition,
offline: isOffline ? "true" : "false",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
},
[item, selectedOptions, isOffline, router],
);
const handlePlay = () => { const handlePlay = () => {
if (!item || !selectedOptions) return; if (!item || !selectedOptions) return;
const hasPlaybackProgress = const queryParams = new URLSearchParams({
(item.UserData?.PlaybackPositionTicks ?? 0) > 0; 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",
});
if (hasPlaybackProgress) { router.push(`/player/direct-player?${queryParams.toString()}`);
Alert.alert(
t("item_card.resume_playback"),
t("item_card.resume_playback_description"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("item_card.play_from_start"),
onPress: () => navigateToPlayer("0"),
},
{
text: t("item_card.continue_from", {
time: formatDuration(item.UserData?.PlaybackPositionTicks),
}),
onPress: () =>
navigateToPlayer(
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
),
isPreferred: true,
},
],
);
} else {
navigateToPlayer("0");
}
}; };
// TV Option Modal hook for quality, audio, media source selectors // TV Option Modal hook for quality, audio, media source selectors
@@ -224,6 +167,10 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
null, null,
); );
// State for last option button ref (used for upward focus guide from cast)
const [_lastOptionButtonRef, setLastOptionButtonRef] =
useState<View | null>(null);
// Get available audio tracks // Get available audio tracks
const audioTracks = useMemo(() => { const audioTracks = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter( const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
@@ -245,16 +192,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
null, null,
); );
// State to trigger refresh of local subtitles list
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
// Starting index for local (client-downloaded) subtitles
const LOCAL_SUBTITLE_INDEX_START = -100;
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks) // Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
// Also includes locally downloaded subtitles from OpenSubtitles
const subtitleTracksForModal = useMemo((): Track[] => { const subtitleTracksForModal = useMemo((): Track[] => {
const tracks: Track[] = subtitleStreams.map((stream) => ({ return subtitleStreams.map((stream) => ({
name: name:
stream.DisplayTitle || stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`, `${stream.Language || "Unknown"} (${stream.Codec})`,
@@ -263,37 +203,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
handleSubtitleChangeRef.current?.(stream.Index ?? -1); handleSubtitleChangeRef.current?.(stream.Index ?? -1);
}, },
})); }));
}, [subtitleStreams]);
// Add locally downloaded subtitles (from OpenSubtitles)
if (item?.Id) {
const localSubs = getSubtitlesForItem(item.Id);
let localIdx = 0;
for (const localSub of localSubs) {
// Verify file still exists (cache may have been cleared)
const subtitleFile = new File(localSub.filePath);
if (!subtitleFile.exists) {
continue;
}
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
tracks.push({
name: localSub.name,
index: localIndex,
isLocal: true,
localPath: localSub.filePath,
setTrack: () => {
// For ItemContent (outside player), just update the selected index
// The actual subtitle will be loaded when playback starts
handleSubtitleChangeRef.current?.(localIndex);
},
});
localIdx++;
}
}
return tracks;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]);
// Get available media sources // Get available media sources
const mediaSources = useMemo(() => { const mediaSources = useMemo(() => {
@@ -385,12 +295,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
} }
}, [item?.Id, queryClient]); }, [item?.Id, queryClient]);
// Handle local subtitle download - trigger refresh of subtitle tracks
const handleLocalSubtitleDownloaded = useCallback((_path: string) => {
// Increment the refresh key to trigger re-computation of subtitleTracksForModal
setLocalSubtitlesRefreshKey((prev) => prev + 1);
}, []);
// Refresh subtitle tracks by fetching fresh item data from Jellyfin // Refresh subtitle tracks by fetching fresh item data from Jellyfin
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => { const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
if (!api || !item?.Id) return []; if (!api || !item?.Id) return [];
@@ -418,7 +322,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
) ?? []; ) ?? [];
// Convert to Track[] with setTrack callbacks // Convert to Track[] with setTrack callbacks
const tracks: Track[] = streams.map((stream) => ({ return streams.map((stream) => ({
name: name:
stream.DisplayTitle || stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`, `${stream.Language || "Unknown"} (${stream.Codec})`,
@@ -427,30 +331,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
handleSubtitleChangeRef.current?.(stream.Index ?? -1); handleSubtitleChangeRef.current?.(stream.Index ?? -1);
}, },
})); }));
// Add locally downloaded subtitles
if (item?.Id) {
const localSubs = getSubtitlesForItem(item.Id);
let localIdx = 0;
for (const localSub of localSubs) {
const subtitleFile = new File(localSub.filePath);
if (!subtitleFile.exists) continue;
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
tracks.push({
name: localSub.name,
index: localIndex,
isLocal: true,
localPath: localSub.filePath,
setTrack: () => {
handleSubtitleChangeRef.current?.(localIndex);
},
});
localIdx++;
}
}
return tracks;
} catch (error) { } catch (error) {
console.error("Failed to refresh subtitle tracks:", error); console.error("Failed to refresh subtitle tracks:", error);
return []; return [];
@@ -468,30 +348,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const selectedSubtitleLabel = useMemo(() => { const selectedSubtitleLabel = useMemo(() => {
if (selectedOptions?.subtitleIndex === -1) if (selectedOptions?.subtitleIndex === -1)
return t("item_card.subtitles.none"); return t("item_card.subtitles.none");
// Check if it's a local subtitle (negative index starting at -100)
if (
selectedOptions?.subtitleIndex !== undefined &&
selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START
) {
const localTrack = subtitleTracksForModal.find(
(t) => t.index === selectedOptions.subtitleIndex,
);
return localTrack?.name || t("item_card.subtitles.label");
}
const track = subtitleStreams.find( const track = subtitleStreams.find(
(t) => t.Index === selectedOptions?.subtitleIndex, (t) => t.Index === selectedOptions?.subtitleIndex,
); );
return ( return (
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label") track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
); );
}, [ }, [subtitleStreams, selectedOptions?.subtitleIndex, t]);
subtitleStreams,
subtitleTracksForModal,
selectedOptions?.subtitleIndex,
t,
]);
const selectedMediaSourceLabel = useMemo(() => { const selectedMediaSourceLabel = useMemo(() => {
const source = selectedOptions?.mediaSource; const source = selectedOptions?.mediaSource;
@@ -562,6 +425,25 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`; return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
}, [api, item]); }, [api, item]);
// Determine which option button is the last one (for focus guide targeting)
const lastOptionButton = useMemo(() => {
const hasSubtitleOption =
subtitleStreams.length > 0 ||
selectedOptions?.subtitleIndex !== undefined;
const hasAudioOption = audioTracks.length > 0;
const hasMediaSourceOption = mediaSources.length > 1;
if (hasSubtitleOption) return "subtitle";
if (hasAudioOption) return "audio";
if (hasMediaSourceOption) return "mediaSource";
return "quality";
}, [
subtitleStreams.length,
selectedOptions?.subtitleIndex,
audioTracks.length,
mediaSources.length,
]);
// Navigation handlers // Navigation handlers
const handleActorPress = useCallback( const handleActorPress = useCallback(
(personId: string) => { (personId: string) => {
@@ -587,7 +469,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const handleEpisodePress = useCallback( const handleEpisodePress = useCallback(
(episode: BaseItemDto) => { (episode: BaseItemDto) => {
const navigation = getItemNavigation(episode, "(home)"); const navigation = getItemNavigation(episode, "(home)");
router.replace(navigation as any); router.push(navigation as any);
}, },
[router], [router],
); );
@@ -752,24 +634,27 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text> </Text>
</TVButton> </TVButton>
<TVFavoriteButton item={item} /> <TVFavoriteButton item={item} />
<TVPlayedButton item={item} />
<TVRefreshButton itemId={item.Id} /> <TVRefreshButton itemId={item.Id} />
</View> </View>
{/* Playback options */} {/* Playback options */}
<View <View
style={{ style={{
flexDirection: "row", flexDirection: "column",
alignItems: "center", alignItems: "flex-start",
gap: 12, gap: 10,
marginBottom: 24, marginBottom: 24,
}} }}
> >
{/* Quality selector */} {/* Quality selector */}
<TVOptionButton <TVOptionButton
ref={
lastOptionButton === "quality"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.quality")} label={t("item_card.quality")}
value={selectedQualityLabel} value={selectedQualityLabel}
maxWidth={200}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: t("item_card.quality"), title: t("item_card.quality"),
@@ -782,9 +667,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{/* Media source selector (only if multiple sources) */} {/* Media source selector (only if multiple sources) */}
{mediaSources.length > 1 && ( {mediaSources.length > 1 && (
<TVOptionButton <TVOptionButton
ref={
lastOptionButton === "mediaSource"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.video")} label={t("item_card.video")}
value={selectedMediaSourceLabel} value={selectedMediaSourceLabel}
maxWidth={280}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: t("item_card.video"), title: t("item_card.video"),
@@ -798,9 +687,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{/* Audio selector */} {/* Audio selector */}
{audioTracks.length > 0 && ( {audioTracks.length > 0 && (
<TVOptionButton <TVOptionButton
ref={
lastOptionButton === "audio"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.audio")} label={t("item_card.audio")}
value={selectedAudioLabel} value={selectedAudioLabel}
maxWidth={280}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: t("item_card.audio"), title: t("item_card.audio"),
@@ -815,9 +708,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{(subtitleStreams.length > 0 || {(subtitleStreams.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && ( selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton <TVOptionButton
ref={
lastOptionButton === "subtitle"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.subtitles.label")} label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel} value={selectedSubtitleLabel}
maxWidth={280}
onPress={() => onPress={() =>
showSubtitleModal({ showSubtitleModal({
item, item,
@@ -828,8 +725,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
onDisableSubtitles: () => handleSubtitleChange(-1), onDisableSubtitles: () => handleSubtitleChange(-1),
onServerSubtitleDownloaded: onServerSubtitleDownloaded:
handleServerSubtitleDownloaded, handleServerSubtitleDownloaded,
onLocalSubtitleDownloaded:
handleLocalSubtitleDownloaded,
refreshSubtitleTracks, refreshSubtitleTracks,
}) })
} }
@@ -911,13 +806,26 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{t("item_card.more_from_this_season")} {t("item_card.more_from_this_season")}
</Text> </Text>
<TVEpisodeList <ScrollView
episodes={seasonEpisodes} horizontal
currentEpisodeId={item.Id} showsHorizontalScrollIndicator={false}
onEpisodePress={handleEpisodePress} style={{ marginHorizontal: -80, overflow: "visible" }}
onEpisodeLongPress={showItemActions} contentContainerStyle={{
firstEpisodeRefSetter={setFirstEpisodeRef} paddingHorizontal: 80,
/> paddingVertical: 12,
gap: 24,
}}
>
{seasonEpisodes.map((episode, index) => (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => handleEpisodePress(episode)}
disabled={episode.Id === item.Id}
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
/>
))}
</ScrollView>
</View> </View>
)} )}

View File

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

View File

@@ -1,11 +1,4 @@
import { import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
Button,
ContextMenu,
Host,
Picker,
Text as SwiftUIText,
} from "@expo/ui/swift-ui";
import { disabled, tag } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
@@ -262,31 +255,22 @@ const PlatformDropdownComponent = ({
if (radioOptions.length > 0) { if (radioOptions.length > 0) {
if (group.title) { if (group.title) {
// Use Picker for grouped options // Use Picker for grouped options
const selectedRadio = radioOptions.find(
(opt) => opt.selected,
);
items.push( items.push(
<Picker <Picker
key={`picker-${groupIndex}`} key={`picker-${groupIndex}`}
label={group.title} label={group.title}
selection={selectedRadio?.value} options={radioOptions.map((opt) => opt.label)}
onSelectionChange={(value) => { variant='menu'
const selectedOption = radioOptions.find( selectedIndex={radioOptions.findIndex(
(opt) => opt.value === value, (opt) => opt.selected,
); )}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
const selectedOption = radioOptions[index];
selectedOption?.onPress(); selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value); onOptionSelect?.(selectedOption?.value);
}} }}
> />,
{radioOptions.map((opt) => (
<SwiftUIText
key={String(opt.value)}
modifiers={[tag(opt.value)]}
>
{opt.label}
</SwiftUIText>
))}
</Picker>,
); );
} else { } else {
// Render radio options as direct buttons // Render radio options as direct buttons
@@ -297,15 +281,13 @@ const PlatformDropdownComponent = ({
systemImage={ systemImage={
option.selected ? "checkmark.circle.fill" : "circle" option.selected ? "checkmark.circle.fill" : "circle"
} }
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => { onPress={() => {
option.onPress(); option.onPress();
onOptionSelect?.(option.value); onOptionSelect?.(option.value);
}} }}
disabled={option.disabled}
> >
<Text>{option.label}</Text> {option.label}
</Button>, </Button>,
); );
}); });
@@ -320,13 +302,13 @@ const PlatformDropdownComponent = ({
systemImage={ systemImage={
option.value ? "checkmark.circle.fill" : "circle" option.value ? "checkmark.circle.fill" : "circle"
} }
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => { onPress={() => {
option.onToggle(); option.onToggle();
onOptionSelect?.(option.value); onOptionSelect?.(option.value);
}} }}
disabled={option.disabled}
> >
<Text>{option.label}</Text> {option.label}
</Button>, </Button>,
); );
}); });
@@ -336,12 +318,12 @@ const PlatformDropdownComponent = ({
items.push( items.push(
<Button <Button
key={`action-${groupIndex}-${optionIndex}`} key={`action-${groupIndex}-${optionIndex}`}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => { onPress={() => {
option.onPress(); option.onPress();
}} }}
disabled={option.disabled}
> >
<Text>{option.label}</Text> {option.label}
</Button>, </Button>,
); );
}); });

View File

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

View File

@@ -69,7 +69,6 @@ export const PlayButton: React.FC<Props> = ({
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();

View File

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

View File

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

View File

@@ -1,532 +0,0 @@
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
KeyboardAvoidingView,
Linking,
Platform,
ScrollView,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { sendCredentialsToTV } from "@/utils/pairingService";
type ScreenState =
| "scanning"
| "no-permission"
| "confirm"
| "form"
| "sending"
| "success"
| "error";
interface ParsedPairingCode {
code: string;
}
type ExpoCameraModule = typeof import("expo-camera");
const ExpoCamera: ExpoCameraModule | null = Platform.isTV
? null
: require("expo-camera");
export const CompanionLoginScreen: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [screenState, setScreenState] = useState<ScreenState>(
Platform.isTV ? "form" : "scanning",
);
const [pairingCode, setPairingCode] = useState<string>("");
const [serverUrl, setServerUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Pre-fill server URL and username from current session
useEffect(() => {
if (api?.basePath) {
setServerUrl(api.basePath);
}
if (user?.Name) {
setUsername(user.Name);
}
}, [api?.basePath, user?.Name]);
// Request camera permission
useEffect(() => {
if (!ExpoCamera) return;
ExpoCamera.Camera.getCameraPermissionsAsync().then((response) => {
if (!response.granted) {
ExpoCamera.Camera.requestCameraPermissionsAsync().then((result) => {
if (!result.granted) {
setScreenState("no-permission");
}
});
}
});
}, []);
const validateAndParseQR = useCallback(
(data: string): ParsedPairingCode | null => {
try {
const parsed = JSON.parse(data);
if (
parsed.action === "streamyfin-pair" &&
typeof parsed.code === "string" &&
parsed.code.length > 0
) {
return { code: parsed.code };
}
return null;
} catch {
return null;
}
},
[],
);
const handleBarCodeScanned = useCallback(
({ data }: { data: string }) => {
if (screenState !== "scanning") return;
const parsed = validateAndParseQR(data);
if (!parsed) {
setErrorMessage(t("companion_login.error_invalid_qr"));
setScreenState("error");
return;
}
setPairingCode(parsed.code);
// If user is logged in, show confirmation screen (still needs password)
// Otherwise, go straight to the full form
if (user?.Name && api?.basePath) {
setScreenState("confirm");
} else {
setScreenState("form");
}
},
[screenState, validateAndParseQR, t, user?.Name, api?.basePath],
);
const handleSendCredentials = useCallback(async () => {
if (
!serverUrl.trim() ||
!username.trim() ||
!password.trim() ||
!pairingCode
) {
return;
}
setScreenState("sending");
try {
await sendCredentialsToTV(
pairingCode,
serverUrl.trim(),
username.trim(),
password,
);
setScreenState("success");
} catch {
setErrorMessage(t("companion_login.error_generic"));
setScreenState("error");
}
}, [pairingCode, serverUrl, username, password, t]);
const handleScanAgain = useCallback(() => {
setPairingCode("");
setErrorMessage(null);
setPassword("");
setScreenState("scanning");
}, []);
const handleDone = useCallback(() => {
router.back();
}, [router]);
const handleUseDifferentUser = useCallback(() => {
setUsername("");
setPassword("");
setScreenState("form");
}, []);
const handleEnterCodeManually = useCallback(() => {
setScreenState("form");
}, []);
if (screenState === "no-permission") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.error_permission_denied")}
</Text>
{Platform.OS === "ios" && (
<TouchableOpacity
onPress={() => Linking.openSettings()}
className='mt-4 rounded-lg bg-purple-600 px-6 py-3'
>
<Text className='text-base font-semibold text-white'>
{t("companion_login.open_settings")}
</Text>
</TouchableOpacity>
)}
<Button
onPress={handleDone}
color='white'
className='mt-4'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
);
}
if (screenState === "success") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.success_title")}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{t("companion_login.pairing_tv_connecting")}
</Text>
<Button
onPress={handleDone}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
);
}
if (screenState === "error") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.error_title")}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{errorMessage}
</Text>
<View className='mt-4 flex-row gap-3'>
<Button
onPress={handleScanAgain}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.scan_again")}
</Button>
<Button
onPress={handleDone}
color='white'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
</View>
);
}
if (screenState === "sending") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='text-xl text-white'>
{t("companion_login.authorizing")}
</Text>
</View>
</View>
);
}
if (screenState === "confirm") {
return (
<KeyboardAvoidingView
className='flex-1 bg-black'
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
padding: 24,
}}
keyboardShouldPersistTaps='handled'
>
<Text className='mb-2 text-center text-2xl font-bold text-white'>
{t("companion_login.login_as", { username })}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{t("companion_login.on_server", {
server: serverUrl.replace(/^https?:\/\//, ""),
})}
</Text>
<View className='mb-6 items-center'>
<Text className='mb-1 text-sm text-gray-400'>
{t("companion_login.pairing_code_label")}
</Text>
<Text className='mb-8 text-center text-4xl font-bold tracking-[6px] text-white'>
{pairingCode}
</Text>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.password_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={password}
onChangeText={setPassword}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
returnKeyType='done'
onSubmitEditing={handleSendCredentials}
autoFocus
/>
</View>
<View className='mt-2'>
<Button
onPress={handleSendCredentials}
disabled={!password.trim()}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.authorize_button")}
</Button>
</View>
<View className='mt-6 items-center'>
<TouchableOpacity onPress={handleUseDifferentUser} className='py-2'>
<Text className='text-base text-gray-400 underline'>
{t("companion_login.use_different_user")}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleScanAgain} className='py-2'>
<Text className='text-sm text-gray-500 underline'>
{t("companion_login.scan_again")}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
if (screenState === "form") {
return (
<KeyboardAvoidingView
className='flex-1 bg-black'
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
padding: 14,
}}
keyboardShouldPersistTaps='handled'
>
<Text className='mb-2 text-2xl font-bold text-white'>
{t("companion_login.pairing_enter_credentials")}
</Text>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("companion_login.pairing_code_label")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-center text-2xl font-bold tracking-[6px] text-white'
value={pairingCode}
onChangeText={setPairingCode}
placeholder={t("companion_login.pairing_code_label")}
placeholderTextColor='#6B7280'
autoCapitalize='characters'
autoCorrect={false}
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("companion_login.server")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={serverUrl}
onChangeText={setServerUrl}
placeholder={t("server.server_url_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
keyboardType='url'
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.username_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={username}
onChangeText={setUsername}
placeholder={t("login.username_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.password_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={password}
onChangeText={setPassword}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
returnKeyType='done'
onSubmitEditing={handleSendCredentials}
/>
</View>
<View className='flex-row justify-center gap-3'>
<Button
onPress={handleScanAgain}
color='black'
className='w-40 border border-neutral-700 bg-neutral-800'
textClassName='flex-1 text-center'
>
{t("companion_login.scan_again")}
</Button>
<Button
onPress={handleSendCredentials}
disabled={
!serverUrl.trim() ||
!username.trim() ||
!password.trim() ||
!pairingCode.trim()
}
className='w-40'
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.authorize_button")}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const CameraView = ExpoCamera?.CameraView;
if (!CameraView) {
return (
<View className='flex-1 bg-black items-center justify-center p-8'>
<Button
onPress={handleEnterCodeManually}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.enter_code_manually")}
</Button>
</View>
);
}
return (
<View className='flex-1 bg-black items-center justify-center'>
{/* Camera full screen */}
<CameraView
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr"],
}}
/>
{/* Dark overlay */}
<View className='absolute inset-0 bg-black/60' />
{/* Center scan area */}
<View className='items-center'>
<View className='h-[250px] w-[250px] rounded-2xl border-2 border-white/80' />
<Text className='mt-6 text-center text-base text-white'>
{t("companion_login.align_qr")}
</Text>
<TouchableOpacity
onPress={handleEnterCodeManually}
className='mt-4 px-5 py-2'
>
<Text className='text-sm text-gray-400 underline'>
{t("companion_login.enter_code_manually")}
</Text>
</TouchableOpacity>
</View>
</View>
);
};

View File

@@ -178,6 +178,8 @@ export const Favorites = () => {
contentContainerStyle={{ contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING, paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60, paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}} }}
> >
<View style={{ gap: SECTION_GAP }}> <View style={{ gap: SECTION_GAP }}>

View File

@@ -36,17 +36,14 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { scaleSize } from "@/utils/scaleSize";
import { updateTVDiscovery } from "@/utils/tvDiscovery/sync";
const HORIZONTAL_PADDING = scaleSize(60); const HORIZONTAL_PADDING = 60;
const TOP_PADDING = scaleSize(100); const TOP_PADDING = 100;
// Generous gap between sections for Apple TV+ aesthetic // Generous gap between sections for Apple TV+ aesthetic
const SECTION_GAP = scaleSize(24); const SECTION_GAP = 24;
type InfiniteScrollingCollectionListSection = { type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList"; type: "InfiniteScrollingCollectionList";
@@ -55,6 +52,7 @@ type InfiniteScrollingCollectionListSection = {
queryFn: QueryFunction<BaseItemDto[], any, number>; queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical";
pageSize?: number; pageSize?: number;
priority?: 1 | 2;
parentId?: string; parentId?: string;
}; };
@@ -79,7 +77,7 @@ export const Home = () => {
retryCheck, retryCheck,
} = useNetworkStatus(); } = useNetworkStatus();
const _invalidateCache = useInvalidatePlaybackProgressCache(); const _invalidateCache = useInvalidatePlaybackProgressCache();
const { showItemActions } = useTVItemActionModal(); const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
// Dynamic backdrop state with debounce // Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null); const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
@@ -252,25 +250,13 @@ export const Home = () => {
deduped.push(item); deduped.push(item);
} }
return deduped.slice(0, 15); return deduped.slice(0, 8);
}, },
enabled: !!api && !!user?.Id, enabled: !!api && !!user?.Id,
staleTime: 60 * 1000, staleTime: 60 * 1000,
refetchInterval: 60 * 1000, refetchInterval: 60 * 1000,
}); });
useEffect(() => {
updateTVDiscovery({
api,
sections: [
{
title: t("home.continue_and_next_up"),
items: heroItems,
},
],
});
}, [api, heroItems, t]);
const userViews = useMemo( const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries], [data, settings?.hiddenLibraries],
@@ -395,6 +381,7 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList", type: "InfiniteScrollingCollectionList",
orientation: "horizontal", orientation: "horizontal",
pageSize: 10, pageSize: 10,
priority: 1,
}, },
] ]
: [ : [
@@ -414,6 +401,7 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList", type: "InfiniteScrollingCollectionList",
orientation: "horizontal", orientation: "horizontal",
pageSize: 10, pageSize: 10,
priority: 1,
}, },
{ {
title: t("home.next_up"), title: t("home.next_up"),
@@ -431,12 +419,13 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList", type: "InfiniteScrollingCollectionList",
orientation: "horizontal", orientation: "horizontal",
pageSize: 10, pageSize: 10,
priority: 1,
}, },
]; ];
const ss: Section[] = [ const ss: Section[] = [
...firstSections, ...firstSections,
...latestMediaViews, ...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
...(!settings?.streamyStatsMovieRecommendations ...(!settings?.streamyStatsMovieRecommendations
? [ ? [
{ {
@@ -455,6 +444,7 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList" as const, type: "InfiniteScrollingCollectionList" as const,
orientation: "vertical" as const, orientation: "vertical" as const,
pageSize: 10, pageSize: 10,
priority: 2 as const,
}, },
] ]
: []), : []),
@@ -539,6 +529,7 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList", type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical", orientation: section?.orientation || "vertical",
pageSize, pageSize,
priority: index < 2 ? 1 : 2,
}); });
}); });
return ss; return ss;
@@ -546,21 +537,23 @@ export const Home = () => {
const sections = settings?.home?.sections ? customSections : defaultSections; const sections = settings?.home?.sections ? customSections : defaultSections;
// Determine if hero should be shown (separate setting from backdrop) const highPrioritySectionKeys = useMemo(() => {
// We need this early to calculate which sections will actually be rendered return sections
const showHero = useMemo(() => { .filter((s) => s.priority === 1)
return heroItems && heroItems.length > 0 && settings.showTVHeroCarousel; .map((s) => s.queryKey.join("-"));
}, [heroItems, settings.showTVHeroCarousel]); }, [sections]);
// Get sections that will actually be rendered (accounting for hero slicing) const allHighPriorityLoaded = useMemo(() => {
// When hero is shown, skip the first sections since hero already displays that content return highPrioritySectionKeys.every((key) => loadedSections.has(key));
// - If mergeNextUpAndContinueWatching: skip 1 section (combined Continue & Next Up) }, [highPrioritySectionKeys, loadedSections]);
// - Otherwise: skip 2 sections (separate Continue Watching + Next Up)
const renderedSections = useMemo(() => { const markSectionLoaded = useCallback(
if (!showHero) return sections; (queryKey: (string | undefined | null)[]) => {
const sectionsToSkip = settings.mergeNextUpAndContinueWatching ? 1 : 2; const key = queryKey.join("-");
return sections.slice(sectionsToSkip); setLoadedSections((prev) => new Set(prev).add(key));
}, [sections, showHero, settings.mergeNextUpAndContinueWatching]); },
[],
);
if (!isConnected || serverConnected !== true) { if (!isConnected || serverConnected !== true) {
let title = ""; let title = "";
@@ -668,6 +661,10 @@ export const Home = () => {
</View> </View>
); );
// Determine if hero should be shown (separate setting from backdrop)
const showHero =
heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
return ( return (
<View style={{ flex: 1, backgroundColor: "#000000" }}> <View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */} {/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
@@ -740,29 +737,28 @@ export const Home = () => {
}} }}
> >
{/* Hero Carousel - Apple TV+ style featured content */} {/* Hero Carousel - Apple TV+ style featured content */}
{showHero && heroItems && ( {showHero && (
<TVHeroCarousel <TVHeroCarousel items={heroItems} onItemFocus={handleItemFocus} />
items={heroItems}
onItemFocus={handleItemFocus}
onItemLongPress={showItemActions}
/>
)} )}
<View <View
style={{ style={{
gap: SECTION_GAP, gap: SECTION_GAP,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingTop: showHero ? SECTION_GAP : 0, paddingTop: showHero ? SECTION_GAP : 0,
}} }}
> >
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */} {/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
{renderedSections.map((section, index) => { {sections.slice(showHero ? 1 : 0).map((section, index) => {
// Render Streamystats sections after Recently Added sections // Render Streamystats sections after Recently Added sections
// For default sections: place after Recently Added, before Suggested Movies (if present) // For default sections: place after Recently Added, before Suggested Movies (if present)
// For custom sections: place at the very end // For custom sections: place at the very end
const hasSuggestedMovies = const hasSuggestedMovies =
!settings?.streamyStatsMovieRecommendations && !settings?.streamyStatsMovieRecommendations &&
!settings?.home?.sections; !settings?.home?.sections;
const displayedSectionsLength = renderedSections.length; // Adjust index calculation to account for sliced array when hero is shown
const displayedSectionsLength =
sections.length - (showHero ? 1 : 0);
const streamystatsIndex = const streamystatsIndex =
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0); displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
const hasStreamystatsContent = const hasStreamystatsContent =
@@ -778,6 +774,7 @@ export const Home = () => {
"home.settings.plugins.streamystats.recommended_movies", "home.settings.plugins.streamystats.recommended_movies",
)} )}
type='Movie' type='Movie'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus} onItemFocus={handleItemFocus}
/> />
)} )}
@@ -787,11 +784,13 @@ export const Home = () => {
"home.settings.plugins.streamystats.recommended_series", "home.settings.plugins.streamystats.recommended_series",
)} )}
type='Series' type='Series'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus} onItemFocus={handleItemFocus}
/> />
)} )}
{settings.streamyStatsPromotedWatchlists && ( {settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists <StreamystatsPromotedWatchlists
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus} onItemFocus={handleItemFocus}
/> />
)} )}
@@ -799,6 +798,7 @@ export const Home = () => {
) : null; ) : null;
if (section.type === "InfiniteScrollingCollectionList") { if (section.type === "InfiniteScrollingCollectionList") {
const isHighPriority = section.priority === 1;
// First section only gets preferred focus if hero is not shown // First section only gets preferred focus if hero is not shown
const isFirstSection = index === 0 && !showHero; const isFirstSection = index === 0 && !showHero;
return ( return (
@@ -810,6 +810,12 @@ export const Home = () => {
orientation={section.orientation} orientation={section.orientation}
hideIfEmpty hideIfEmpty
pageSize={section.pageSize} pageSize={section.pageSize}
enabled={isHighPriority || allHighPriorityLoaded}
onLoaded={
isHighPriority
? () => markSectionLoaded(section.queryKey)
: undefined
}
isFirstSection={isFirstSection} isFirstSection={isFirstSection}
onItemFocus={handleItemFocus} onItemFocus={handleItemFocus}
parentId={section.parentId} parentId={section.parentId}

View File

@@ -6,7 +6,7 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -16,18 +16,21 @@ import {
} from "react-native"; } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; 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"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { scaleSize } from "@/utils/scaleSize"; import ContinueWatchingPoster, {
TV_LANDSCAPE_WIDTH,
} from "../ContinueWatchingPoster.tv";
import SeriesPoster from "../posters/SeriesPoster.tv";
const ITEM_GAP = 24;
// Extra padding to accommodate scale animation (1.05x) and glow shadow // Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = scaleSize(20); const SCALE_PADDING = 20;
interface Props extends ViewProps { interface Props extends ViewProps {
title?: string | null; title?: string | null;
@@ -39,13 +42,64 @@ interface Props extends ViewProps {
pageSize?: number; pageSize?: number;
onPressSeeAll?: () => void; onPressSeeAll?: () => void;
enabled?: boolean; enabled?: boolean;
onLoaded?: () => void;
isFirstSection?: boolean; isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void; onItemFocus?: (item: BaseItemDto) => void;
parentId?: string; parentId?: string;
} }
type Typography = ReturnType<typeof useScaledTVTypography>; type Typography = ReturnType<typeof useScaledTVTypography>;
type PosterSizes = ReturnType<typeof useScaledTVPosterSizes>;
// TV-specific ItemCardText with larger fonts
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
</>
) : (
<>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</>
)}
</View>
);
};
// TV-specific "See All" card for end of lists // TV-specific "See All" card for end of lists
const TVSeeAllCard: React.FC<{ const TVSeeAllCard: React.FC<{
@@ -55,19 +109,10 @@ const TVSeeAllCard: React.FC<{
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
typography: Typography; typography: Typography;
posterSizes: PosterSizes; }> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
}> = ({
onPress,
orientation,
disabled,
onFocus,
onBlur,
typography,
posterSizes,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const width = const width =
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15; const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
return ( return (
@@ -82,7 +127,7 @@ const TVSeeAllCard: React.FC<{
style={{ style={{
width, width,
aspectRatio, aspectRatio,
borderRadius: scaleSize(24), borderRadius: 24,
backgroundColor: "rgba(255, 255, 255, 0.08)", backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
@@ -92,9 +137,9 @@ const TVSeeAllCard: React.FC<{
> >
<Ionicons <Ionicons
name='arrow-forward' name='arrow-forward'
size={scaleSize(32)} size={32}
color='white' color='white'
style={{ marginBottom: scaleSize(8) }} style={{ marginBottom: 8 }}
/> />
<Text <Text
style={{ style={{
@@ -120,49 +165,71 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
hideIfEmpty = false, hideIfEmpty = false,
pageSize = 10, pageSize = 10,
enabled = true, enabled = true,
onLoaded,
isFirstSection = false, isFirstSection = false,
onItemFocus, onItemFocus,
parentId, parentId,
...props ...props
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const effectivePageSize = Math.max(1, pageSize); const effectivePageSize = Math.max(1, pageSize);
const hasCalledOnLoaded = useRef(false);
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
// Track focus within section for item focus/blur callbacks
const flatListRef = useRef<FlatList<BaseItemDto>>(null); const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [_focusedCount, setFocusedCount] = useState(0);
// Pass through focus callbacks without tracking internal state
const handleItemFocus = useCallback( const handleItemFocus = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
setFocusedCount((c) => c + 1);
onItemFocus?.(item); onItemFocus?.(item);
}, },
[onItemFocus], [onItemFocus],
); );
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = const handleItemBlur = useCallback(() => {
useInfiniteQuery({ setFocusedCount((c) => Math.max(0, c - 1));
queryKey: queryKey, }, []);
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }), // Focus handler for See All card (doesn't need item parameter)
getNextPageParam: (lastPage, allPages) => { const handleSeeAllFocus = useCallback(() => {
if (lastPage.length < effectivePageSize) { setFocusedCount((c) => c + 1);
return undefined; }, []);
}
return allPages.reduce((acc, page) => acc + page.length, 0); const {
}, data,
initialPageParam: 0, isLoading,
staleTime: 60 * 1000, isFetchingNextPage,
refetchInterval: 60 * 1000, hasNextPage,
refetchOnWindowFocus: false, fetchNextPage,
refetchOnReconnect: true, isSuccess,
enabled, } = 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,
});
useEffect(() => {
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
hasCalledOnLoaded.current = true;
onLoaded();
}
}, [isSuccess, onLoaded]);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -183,7 +250,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}, [data]); }, [data]);
const itemWidth = const itemWidth =
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
const handleItemPress = useCallback( const handleItemPress = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
@@ -211,21 +278,79 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
} as any); } as any);
}, [router, parentId]); }, [router, parentId]);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: itemWidth + ITEM_GAP,
offset: (itemWidth + ITEM_GAP) * index,
index,
}),
[itemWidth],
);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => { ({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0; const isFirstItem = isFirstSection && index === 0;
const isHorizontal = orientation === "horizontal";
const renderPoster = () => {
if (item.Type === "Episode" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Episode" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Movie" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Movie" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Series" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Series" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Program") {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "BoxSet" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "BoxSet" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Playlist" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Playlist" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Video" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Video" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// Default fallback
return isHorizontal ? (
<ContinueWatchingPoster item={item} />
) : (
<MoviePoster item={item} />
);
};
return ( return (
<View style={{ marginRight: ITEM_GAP }}> <View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
<TVPosterCard <TVFocusablePoster
item={item}
orientation={orientation}
onPress={() => handleItemPress(item)} onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={isFirstItem} hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)} onFocus={() => handleItemFocus(item)}
width={itemWidth} onBlur={handleItemBlur}
/> >
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
</View> </View>
); );
}, },
@@ -234,9 +359,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
isFirstSection, isFirstSection,
itemWidth, itemWidth,
handleItemPress, handleItemPress,
showItemActions,
handleItemFocus, handleItemFocus,
ITEM_GAP, handleItemBlur,
typography,
], ],
); );
@@ -251,8 +376,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "700", fontWeight: "700",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: scaleSize(20), marginBottom: 20,
marginLeft: sizes.padding.horizontal, marginLeft: SCALE_PADDING,
letterSpacing: 0.5, letterSpacing: 0.5,
}} }}
> >
@@ -264,7 +389,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
style={{ style={{
color: "#737373", color: "#737373",
fontSize: typography.callout, fontSize: typography.callout,
marginLeft: sizes.padding.horizontal, marginLeft: SCALE_PADDING,
}} }}
> >
{t("home.no_items")} {t("home.no_items")}
@@ -287,8 +412,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
backgroundColor: "#262626", backgroundColor: "#262626",
width: itemWidth, width: itemWidth,
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15, aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
borderRadius: scaleSize(12), borderRadius: 12,
marginBottom: scaleSize(8), marginBottom: 8,
}} }}
/> />
<View <View
@@ -328,29 +453,18 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
maxToRenderPerBatch={3} maxToRenderPerBatch={3}
windowSize={5} windowSize={5}
removeClippedSubviews={false} removeClippedSubviews={false}
getItemLayout={getItemLayout}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }} maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
contentContainerStyle={{ contentContainerStyle={{
paddingVertical: sizes.gaps.small, paddingVertical: SCALE_PADDING,
paddingLeft: sizes.padding.horizontal, paddingHorizontal: SCALE_PADDING,
paddingRight: sizes.padding.horizontal,
}} }}
// Below is a work around with the contentInset, same in TVHeroCarousel, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={ ListFooterComponent={
<View <View
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
width: sizes.padding.horizontal,
}} }}
> >
{isFetchingNextPage && ( {isFetchingNextPage && (
@@ -359,10 +473,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
marginLeft: itemWidth / 2, marginLeft: itemWidth / 2,
marginRight: ITEM_GAP, marginRight: ITEM_GAP,
justifyContent: "center", justifyContent: "center",
height: height: orientation === "horizontal" ? 191 : 315,
orientation === "horizontal"
? scaleSize(191)
: scaleSize(315),
}} }}
> >
<ActivityIndicator size='small' color='white' /> <ActivityIndicator size='small' color='white' />
@@ -373,8 +484,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onPress={handleSeeAllPress} onPress={handleSeeAllPress}
orientation={orientation} orientation={orientation}
disabled={disabled} disabled={disabled}
onFocus={handleSeeAllFocus}
onBlur={handleItemBlur}
typography={typography} typography={typography}
posterSizes={posterSizes}
/> />
)} )}
</View> </View>

View File

@@ -11,19 +11,47 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { TVPosterCard } from "@/components/tv/TVPosterCard"; import MoviePoster, {
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; TV_POSTER_WIDTH,
import { useScaledTVSizes } from "@/constants/TVSizes"; } from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api"; import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const SCALE_PADDING = scaleSize(20); const ITEM_GAP = 16;
const SCALE_PADDING = 20;
type Typography = ReturnType<typeof useScaledTVTypography>;
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
interface WatchlistSectionProps extends ViewProps { interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist; watchlist: StreamystatsWatchlist;
@@ -38,14 +66,10 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
...props ...props
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
@@ -105,35 +129,30 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
const getItemLayout = useCallback( const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({ (_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: posterSizes.poster + ITEM_GAP, length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (posterSizes.poster + ITEM_GAP) * index, offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
index, index,
}), }),
[posterSizes.poster, ITEM_GAP], [],
); );
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => { ({ item }: { item: BaseItemDto }) => {
return ( return (
<View style={{ marginRight: ITEM_GAP }}> <View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVPosterCard <TVFocusablePoster
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)} onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)} onFocus={() => onItemFocus?.(item)}
width={posterSizes.poster} hasTVPreferredFocus={false}
/> >
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
</View> </View>
); );
}, },
[ [handleItemPress, onItemFocus, typography],
ITEM_GAP,
posterSizes.poster,
handleItemPress,
showItemActions,
onItemFocus,
],
); );
if (!isLoading && (!items || items.length === 0)) return null; if (!isLoading && (!items || items.length === 0)) return null;
@@ -146,7 +165,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
fontWeight: "700", fontWeight: "700",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 20, marginBottom: 20,
marginLeft: sizes.padding.horizontal, marginLeft: SCALE_PADDING,
letterSpacing: 0.5, letterSpacing: 0.5,
}} }}
> >
@@ -163,14 +182,14 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
}} }}
> >
{[1, 2, 3, 4, 5].map((i) => ( {[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: posterSizes.poster }}> <View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View <View
style={{ style={{
backgroundColor: "#262626", backgroundColor: "#262626",
width: posterSizes.poster, width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: scaleSize(12), borderRadius: 12,
marginBottom: scaleSize(8), marginBottom: 8,
}} }}
/> />
</View> </View>
@@ -189,13 +208,9 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
removeClippedSubviews={false} removeClippedSubviews={false}
getItemLayout={getItemLayout} getItemLayout={getItemLayout}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{ contentContainerStyle={{
paddingVertical: SCALE_PADDING, paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}} }}
/> />
)} )}
@@ -211,9 +226,6 @@ interface StreamystatsPromotedWatchlistsProps extends ViewProps {
export const StreamystatsPromotedWatchlists: React.FC< export const StreamystatsPromotedWatchlists: React.FC<
StreamystatsPromotedWatchlistsProps StreamystatsPromotedWatchlistsProps
> = ({ enabled = true, onItemFocus, ...props }) => { > = ({ enabled = true, onItemFocus, ...props }) => {
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const { settings } = useSettings(); const { settings } = useSettings();
@@ -287,12 +299,12 @@ export const StreamystatsPromotedWatchlists: React.FC<
<View style={{ overflow: "visible" }} {...props}> <View style={{ overflow: "visible" }} {...props}>
<View <View
style={{ style={{
height: scaleSize(16), height: 16,
width: scaleSize(128), width: 128,
backgroundColor: "#262626", backgroundColor: "#262626",
borderRadius: scaleSize(4), borderRadius: 4,
marginLeft: SCALE_PADDING, marginLeft: SCALE_PADDING,
marginBottom: scaleSize(16), marginBottom: 16,
}} }}
/> />
<View <View
@@ -304,14 +316,14 @@ export const StreamystatsPromotedWatchlists: React.FC<
}} }}
> >
{[1, 2, 3, 4, 5].map((i) => ( {[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: posterSizes.poster }}> <View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View <View
style={{ style={{
backgroundColor: "#262626", backgroundColor: "#262626",
width: posterSizes.poster, width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: scaleSize(12), borderRadius: 12,
marginBottom: scaleSize(8), marginBottom: 8,
}} }}
/> />
</View> </View>

View File

@@ -11,17 +11,23 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { TVPosterCard } from "@/components/tv/TVPosterCard"; import MoviePoster, {
import { useScaledTVSizes } from "@/constants/TVSizes"; TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api"; import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
type Typography = ReturnType<typeof useScaledTVTypography>;
interface Props extends ViewProps { interface Props extends ViewProps {
title: string; title: string;
type: "Movie" | "Series"; type: "Movie" | "Series";
@@ -30,6 +36,31 @@ interface Props extends ViewProps {
onItemFocus?: (item: BaseItemDto) => void; onItemFocus?: (item: BaseItemDto) => void;
} }
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
export const StreamystatsRecommendations: React.FC<Props> = ({ export const StreamystatsRecommendations: React.FC<Props> = ({
title, title,
type, type,
@@ -39,12 +70,10 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
...props ...props
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
@@ -161,29 +190,30 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
const getItemLayout = useCallback( const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({ (_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: sizes.posters.poster + sizes.gaps.item, length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (sizes.posters.poster + sizes.gaps.item) * index, offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
index, index,
}), }),
[sizes], [],
); );
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => { ({ item }: { item: BaseItemDto }) => {
return ( return (
<View style={{ marginRight: sizes.gaps.item }}> <View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVPosterCard <TVFocusablePoster
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)} onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)} onFocus={() => onItemFocus?.(item)}
width={sizes.posters.poster} hasTVPreferredFocus={false}
/> >
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
</View> </View>
); );
}, },
[sizes, handleItemPress, showItemActions, onItemFocus], [handleItemPress, onItemFocus, typography],
); );
if (!streamyStatsEnabled) return null; if (!streamyStatsEnabled) return null;
@@ -198,7 +228,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
fontWeight: "700", fontWeight: "700",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 20, marginBottom: 20,
marginLeft: sizes.padding.horizontal, marginLeft: SCALE_PADDING,
letterSpacing: 0.5, letterSpacing: 0.5,
}} }}
> >
@@ -209,20 +239,20 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
<View <View
style={{ style={{
flexDirection: "row", flexDirection: "row",
gap: sizes.gaps.item, gap: ITEM_GAP,
paddingHorizontal: sizes.padding.scale, paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
}} }}
> >
{[1, 2, 3, 4, 5].map((i) => ( {[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: sizes.posters.poster }}> <View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View <View
style={{ style={{
backgroundColor: "#262626", backgroundColor: "#262626",
width: sizes.posters.poster, width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: scaleSize(12), borderRadius: 12,
marginBottom: scaleSize(8), marginBottom: 8,
}} }}
/> />
</View> </View>
@@ -241,13 +271,9 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
removeClippedSubviews={false} removeClippedSubviews={false}
getItemLayout={getItemLayout} getItemLayout={getItemLayout}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{ contentContainerStyle={{
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}} }}
/> />
)} )}

View File

@@ -23,7 +23,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ProgressBar } from "@/components/common/ProgressBar"; import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { import {
@@ -33,28 +32,28 @@ import {
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { scaleSize } from "@/utils/scaleSize";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const HERO_HEIGHT = SCREEN_HEIGHT * 0.62;
const CARD_WIDTH = 280;
const CARD_GAP = 24;
const CARD_PADDING = 60;
interface TVHeroCarouselProps { interface TVHeroCarouselProps {
items: BaseItemDto[]; items: BaseItemDto[];
onItemFocus?: (item: BaseItemDto) => void; onItemFocus?: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
} }
interface HeroCardProps { interface HeroCardProps {
item: BaseItemDto; item: BaseItemDto;
isFirst: boolean; isFirst: boolean;
sizes: ScaledTVSizes;
onFocus: (item: BaseItemDto) => void; onFocus: (item: BaseItemDto) => void;
onPress: (item: BaseItemDto) => void; onPress: (item: BaseItemDto) => void;
onLongPress?: (item: BaseItemDto) => void;
} }
const HeroCard: React.FC<HeroCardProps> = React.memo( const HeroCard: React.FC<HeroCardProps> = React.memo(
({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => { ({ item, isFirst, onFocus, onPress }) => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current; const scale = useRef(new Animated.Value(1)).current;
@@ -85,6 +84,8 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
return null; return null;
}, [api, item]); }, [api, item]);
const progress = item.UserData?.PlayedPercentage || 0;
const animateTo = useCallback( const animateTo = useCallback(
(value: number) => (value: number) =>
Animated.timing(scale, { Animated.timing(scale, {
@@ -98,9 +99,9 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
setFocused(true); setFocused(true);
animateTo(sizes.animation.focusScale); animateTo(1.1);
onFocus(item); onFocus(item);
}, [animateTo, onFocus, item, sizes.animation.focusScale]); }, [animateTo, onFocus, item]);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setFocused(false); setFocused(false);
@@ -111,31 +112,25 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
onPress(item); onPress(item);
}, [onPress, item]); }, [onPress, item]);
const handleLongPress = useCallback(() => {
onLongPress?.(item);
}, [onLongPress, item]);
// Use glass poster for tvOS 26+ // Use glass poster for tvOS 26+
if (useGlass && posterUrl) { if (useGlass) {
const progress = item.UserData?.PlayedPercentage || 0;
return ( return (
<Pressable <Pressable
onPress={handlePress} onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
hasTVPreferredFocus={isFirst} hasTVPreferredFocus={isFirst}
style={{ marginRight: sizes.gaps.item }} style={{ marginRight: CARD_GAP }}
> >
<GlassPosterView <GlassPosterView
imageUrl={posterUrl} imageUrl={posterUrl}
aspectRatio={16 / 9} aspectRatio={16 / 9}
cornerRadius={scaleSize(24)} cornerRadius={16}
progress={progress} progress={progress}
showWatchedIndicator={false} showWatchedIndicator={false}
isFocused={focused} isFocused={focused}
width={sizes.posters.episode} width={CARD_WIDTH}
style={{ width: sizes.posters.episode }} style={{ width: CARD_WIDTH }}
/> />
</Pressable> </Pressable>
); );
@@ -145,25 +140,22 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
return ( return (
<Pressable <Pressable
onPress={handlePress} onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
hasTVPreferredFocus={isFirst} hasTVPreferredFocus={isFirst}
style={{ marginRight: sizes.gaps.item }} style={{ marginRight: CARD_GAP }}
> >
<Animated.View <Animated.View
style={{ style={{
width: sizes.posters.episode, width: CARD_WIDTH,
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
borderRadius: scaleSize(24), borderRadius: 16,
overflow: "hidden", overflow: "hidden",
transform: [{ scale }], transform: [{ scale }],
borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent",
shadowColor: "#FFFFFF", shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? scaleSize(20) : 0, shadowRadius: focused ? 20 : 0,
}} }}
> >
{posterUrl ? ( {posterUrl ? (
@@ -184,7 +176,7 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
> >
<Ionicons <Ionicons
name='film-outline' name='film-outline'
size={scaleSize(48)} size={48}
color='rgba(255,255,255,0.3)' color='rgba(255,255,255,0.3)'
/> />
</View> </View>
@@ -202,10 +194,8 @@ const BACKDROP_DEBOUNCE_MS = 300;
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
items, items,
onItemFocus, onItemFocus,
onItemLongPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
@@ -364,13 +354,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
<HeroCard <HeroCard
item={item} item={item}
isFirst={index === 0} isFirst={index === 0}
sizes={sizes}
onFocus={handleCardFocus} onFocus={handleCardFocus}
onPress={handleCardPress} onPress={handleCardPress}
onLongPress={onItemLongPress}
/> />
), ),
[handleCardFocus, handleCardPress, onItemLongPress, sizes], [handleCardFocus, handleCardPress],
); );
// Memoize keyExtractor // Memoize keyExtractor
@@ -378,18 +366,8 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
if (items.length === 0) return null; if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(30) : 0;
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return ( return (
<View <View style={{ height: HERO_HEIGHT, width: "100%" }}>
style={{
height: heroHeight + insets.top + tvosTopPadding,
width: "100%",
paddingTop: insets.top + tvosTopPadding,
}}
>
{/* Backdrop layers with crossfade */} {/* Backdrop layers with crossfade */}
<View <View
style={{ style={{
@@ -474,17 +452,13 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
/> />
</View> </View>
{/* Content overlay - text elements with padding */} {/* Content overlay */}
<View <View
style={{ style={{
position: "absolute", position: "absolute",
left: sizes.padding.horizontal, left: insets.left + CARD_PADDING,
right: sizes.padding.horizontal, right: insets.right + CARD_PADDING,
bottom: bottom: 40,
scaleSize(40) +
sizes.posters.episode * (9 / 16) +
sizes.gaps.small * 2 +
scaleSize(20),
}} }}
> >
{/* Logo or Title */} {/* Logo or Title */}
@@ -492,9 +466,9 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
<Image <Image
source={{ uri: logoUrl }} source={{ uri: logoUrl }}
style={{ style={{
height: scaleSize(100), height: 100,
width: SCREEN_WIDTH * 0.35, width: SCREEN_WIDTH * 0.35,
marginBottom: scaleSize(16), marginBottom: 16,
}} }}
contentFit='contain' contentFit='contain'
contentPosition='left' contentPosition='left'
@@ -505,7 +479,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
fontSize: typography.display, fontSize: typography.display,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: scaleSize(12), marginBottom: 12,
}} }}
numberOfLines={1} numberOfLines={1}
> >
@@ -519,7 +493,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{ style={{
fontSize: typography.body, fontSize: typography.body,
color: "rgba(255,255,255,0.9)", color: "rgba(255,255,255,0.9)",
marginBottom: scaleSize(12), marginBottom: 12,
}} }}
numberOfLines={1} numberOfLines={1}
> >
@@ -533,7 +507,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{ style={{
fontSize: typography.body, fontSize: typography.body,
color: "rgba(255,255,255,0.8)", color: "rgba(255,255,255,0.8)",
marginBottom: scaleSize(16), marginBottom: 16,
maxWidth: SCREEN_WIDTH * 0.5, maxWidth: SCREEN_WIDTH * 0.5,
lineHeight: typography.body * 1.4, lineHeight: typography.body * 1.4,
}} }}
@@ -548,7 +522,8 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: scaleSize(16), gap: 16,
marginBottom: 20,
}} }}
> >
{year && ( {year && (
@@ -574,10 +549,10 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
{activeItem?.OfficialRating && ( {activeItem?.OfficialRating && (
<View <View
style={{ style={{
paddingHorizontal: scaleSize(8), paddingHorizontal: 8,
paddingVertical: scaleSize(2), paddingVertical: 2,
borderRadius: scaleSize(4), borderRadius: 4,
borderWidth: scaleSize(1), borderWidth: 1,
borderColor: "rgba(255,255,255,0.5)", borderColor: "rgba(255,255,255,0.5)",
}} }}
> >
@@ -596,15 +571,15 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: scaleSize(6), gap: 6,
}} }}
> >
<View <View
style={{ style={{
width: scaleSize(60), width: 60,
height: scaleSize(4), height: 4,
backgroundColor: "rgba(255,255,255,0.3)", backgroundColor: "rgba(255,255,255,0.3)",
borderRadius: scaleSize(2), borderRadius: 2,
overflow: "hidden", overflow: "hidden",
}} }}
> >
@@ -628,38 +603,15 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
</View> </View>
)} )}
</View> </View>
</View>
{/* Thumbnail carousel - edge-to-edge */} {/* Thumbnail carousel */}
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: scaleSize(40),
}}
>
<FlatList <FlatList
horizontal horizontal
data={heroItems} data={heroItems}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
contentContainerStyle={{ contentContainerStyle={{ paddingVertical: 12 }}
paddingVertical: sizes.gaps.small,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
}}
// Below is a work around with the contentInset, same in infiniteScrollingCollectionList, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
renderItem={renderHeroCard} renderItem={renderHeroCard}
removeClippedSubviews={false} removeClippedSubviews={false}
initialNumToRender={8} initialNumToRender={8}

View File

@@ -103,8 +103,6 @@ const TVLibraryRow: React.FC<{
return t("library.item_types.series"); return t("library.item_types.series");
if (library.CollectionType === "boxsets") if (library.CollectionType === "boxsets")
return t("library.item_types.boxsets"); return t("library.item_types.boxsets");
if (library.CollectionType === "playlists")
return t("library.item_types.playlists");
if (library.CollectionType === "music") if (library.CollectionType === "music")
return t("library.item_types.items"); return t("library.item_types.items");
return t("library.item_types.items"); return t("library.item_types.items");
@@ -260,7 +258,8 @@ export const TVLibraries: React.FC = () => {
userViews userViews
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books") .filter((l) => l.CollectionType !== "books")
.filter((l) => l.CollectionType !== "music") || [], .filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "playlists") || [],
[userViews, settings?.hiddenLibraries], [userViews, settings?.hiddenLibraries],
); );
@@ -274,10 +273,6 @@ export const TVLibraries: React.FC = () => {
if (library.CollectionType === "movies") itemType = "Movie"; if (library.CollectionType === "movies") itemType = "Movie";
else if (library.CollectionType === "tvshows") itemType = "Series"; else if (library.CollectionType === "tvshows") itemType = "Series";
else if (library.CollectionType === "boxsets") itemType = "BoxSet"; else if (library.CollectionType === "boxsets") itemType = "BoxSet";
else if (library.CollectionType === "playlists")
itemType = "Playlist";
const isPlaylistsLib = library.CollectionType === "playlists";
// Fetch count // Fetch count
const countResponse = await getItemsApi(api!).getItems({ const countResponse = await getItemsApi(api!).getItems({
@@ -286,7 +281,6 @@ export const TVLibraries: React.FC = () => {
recursive: true, recursive: true,
limit: 0, limit: 0,
includeItemTypes: itemType ? [itemType as any] : undefined, includeItemTypes: itemType ? [itemType as any] : undefined,
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
}); });
// Fetch preview items with backdrops // Fetch preview items with backdrops
@@ -298,7 +292,6 @@ export const TVLibraries: React.FC = () => {
sortBy: ["Random"], sortBy: ["Random"],
includeItemTypes: itemType ? [itemType as any] : undefined, includeItemTypes: itemType ? [itemType as any] : undefined,
imageTypes: ["Backdrop"], imageTypes: ["Backdrop"],
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
}); });
return { return {
@@ -316,10 +309,6 @@ export const TVLibraries: React.FC = () => {
const handleLibraryPress = useCallback( const handleLibraryPress = useCallback(
(library: BaseItemDto) => { (library: BaseItemDto) => {
if (library.CollectionType === "livetv") {
router.push("/(auth)/(tabs)/(libraries)/livetv/programs");
return;
}
if (library.CollectionType === "music") { if (library.CollectionType === "music") {
router.push({ router.push({
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`, pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,24 +72,22 @@ export const Login: React.FC = () => {
password: string; password: string;
} | null>(null); } | null>(null);
// Handle URL params for server connection
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (_apiUrl) { if (_apiUrl) {
await setServer({ await setServer({
address: _apiUrl, address: _apiUrl,
}); });
setTimeout(() => {
if (_username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, 0);
} }
})(); })();
}, [_apiUrl]); }, [_apiUrl, _username, _password]);
// 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(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
@@ -382,16 +380,18 @@ export const Login: React.FC = () => {
</View> </View>
</View> </View>
</View> </View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View> </View>
) : ( ) : (
<View className='flex flex-col flex-1 w-full'> <View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full'> <View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image <Image
style={{ style={{
width: 100, width: 100,
height: 100, height: 100,
marginLeft: -23,
marginBottom: -20, marginBottom: -20,
alignSelf: "center",
}} }}
source={require("@/assets/images/icon-ios-plain.png")} source={require("@/assets/images/icon-ios-plain.png")}
/> />
@@ -429,8 +429,6 @@ export const Login: React.FC = () => {
await handleConnect(server.address); await handleConnect(server.address);
}} }}
/> />
</View>
<View className='px-4 pb-2'>
<PreviousServersList <PreviousServersList
onServerSelect={async (s) => { onServerSelect={async (s) => {
await handleConnect(s.address); await handleConnect(s.address);

View File

@@ -3,7 +3,7 @@ import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Animated, Easing, Pressable, View } from "react-native"; import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize"; import { Colors } from "@/constants/Colors";
import type { SavedServerAccount } from "@/utils/secureCredentials"; import type { SavedServerAccount } from "@/utils/secureCredentials";
interface TVAccountCardProps { interface TVAccountCardProps {
@@ -85,7 +85,7 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
style={[ style={[
{ {
transform: [{ scale }], transform: [{ scale }],
shadowColor: "#fff", shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowRadius: 16, shadowRadius: 16,
elevation: 8, elevation: 8,
@@ -98,9 +98,9 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
backgroundColor: isFocused ? "#2a2a2a" : "#262626", backgroundColor: isFocused ? "#2a2a2a" : "#262626",
borderWidth: 2, borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent", borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: scaleSize(16), borderRadius: 16,
paddingHorizontal: scaleSize(24), paddingHorizontal: 24,
paddingVertical: scaleSize(20), paddingVertical: 20,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
}} }}
@@ -108,23 +108,23 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
{/* Avatar */} {/* Avatar */}
<View <View
style={{ style={{
width: scaleSize(56), width: 56,
height: scaleSize(56), height: 56,
backgroundColor: "#404040", backgroundColor: "#404040",
borderRadius: scaleSize(28), borderRadius: 28,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
marginRight: scaleSize(20), marginRight: 20,
}} }}
> >
<Ionicons name='person' size={scaleSize(28)} color='white' /> <Ionicons name='person' size={28} color='white' />
</View> </View>
{/* Account Info */} {/* Account Info */}
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text <Text
style={{ style={{
fontSize: scaleSize(22), fontSize: 22,
fontWeight: "600", fontWeight: "600",
color: "#FFFFFF", color: "#FFFFFF",
}} }}
@@ -133,9 +133,9 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
</Text> </Text>
<Text <Text
style={{ style={{
fontSize: scaleSize(16), fontSize: 16,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: scaleSize(4), marginTop: 4,
}} }}
> >
{getSecurityText()} {getSecurityText()}
@@ -143,11 +143,7 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
</View> </View>
{/* Security Icon */} {/* Security Icon */}
<Ionicons <Ionicons name={getSecurityIcon()} size={24} color={Colors.primary} />
name={getSecurityIcon()}
size={scaleSize(24)}
color='#fff'
/>
</View> </View>
</Animated.View> </Animated.View>
</Pressable> </Pressable>

View File

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

View File

@@ -1,123 +0,0 @@
import { t } from "i18next";
import React, { useCallback, useState } from "react";
import { ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
import { TVInput } from "./TVInput";
interface TVAddServerFormProps {
onConnect: (url: string) => Promise<void>;
onStartPairing?: () => void;
onBack: () => void;
loading?: boolean;
disabled?: boolean;
}
export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
onConnect,
onStartPairing,
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;
const handleBack = useCallback(() => {
if (isDisabled) return false;
onBack();
return true;
}, [isDisabled, onBack]);
useTVBackPress(() => handleBack(), [handleBack]);
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: scaleSize(60),
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: scaleSize(60),
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
textAlign: "left",
marginBottom: scaleSize(24),
paddingHorizontal: scaleSize(8),
}}
>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Server URL Input */}
<View
style={{
marginBottom: scaleSize(24),
paddingHorizontal: scaleSize(8),
}}
>
<TVInput
placeholder={t("server.server_url_placeholder")}
value={serverURL}
onChangeText={setServerURL}
keyboardType='url'
autoCapitalize='none'
textContentType='URL'
returnKeyType='done'
hasTVPreferredFocus
disabled={isDisabled}
/>
</View>
{/* Connect Button */}
<View style={{ marginBottom: scaleSize(24) }}>
<Button
onPress={handleConnect}
loading={loading}
disabled={loading || !serverURL.trim()}
color='white'
>
{t("server.connect_button")}
</Button>
</View>
{/* Pair with Phone */}
{onStartPairing && (
<View>
<Button
onPress={onStartPairing}
className='bg-neutral-800 border border-neutral-700'
>
{t("pairing.pair_with_phone")}
</Button>
</View>
)}
</View>
</ScrollView>
);
};

View File

@@ -1,185 +0,0 @@
import { t } from "i18next";
import React, { useCallback, useState } from "react";
import { ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
import { TVInput } from "./TVInput";
import { TVSaveAccountToggle } from "./TVSaveAccountToggle";
interface TVAddUserFormProps {
serverName: string;
serverAddress: string;
onLogin: (
username: string,
password: string,
saveAccount: boolean,
) => Promise<void>;
onQuickConnect: () => Promise<void>;
onBack: () => void;
loading?: boolean;
disabled?: boolean;
}
export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
serverName,
serverAddress,
onLogin,
onQuickConnect,
onBack,
loading = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const [credentials, setCredentials] = useState({
username: "",
password: "",
});
const [saveAccount, setSaveAccount] = useState(false);
const handleLogin = async () => {
if (credentials.username.trim()) {
await onLogin(credentials.username, credentials.password, saveAccount);
}
};
const isDisabled = disabled || loading;
const handleBack = useCallback(() => {
if (isDisabled) return false;
onBack();
return true;
}, [isDisabled, onBack]);
useTVBackPress(() => handleBack(), [handleBack]);
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: scaleSize(60),
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: scaleSize(60),
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: scaleSize(8),
}}
>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text style={{ color: "#fff" }}>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginBottom: scaleSize(40),
}}
>
{serverAddress}
</Text>
{/* Username Input */}
<View
style={{
marginBottom: scaleSize(24),
paddingHorizontal: scaleSize(8),
}}
>
<TVInput
placeholder={t("login.username_placeholder")}
value={credentials.username}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
returnKeyType='next'
hasTVPreferredFocus
disabled={isDisabled}
/>
</View>
{/* Password Input */}
<View
style={{
marginBottom: scaleSize(32),
paddingHorizontal: scaleSize(8),
}}
>
<TVInput
placeholder={t("login.password_placeholder")}
value={credentials.password}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
secureTextEntry
autoCapitalize='none'
textContentType='password'
returnKeyType='done'
disabled={isDisabled}
/>
</View>
{/* Save Account Toggle */}
<View
style={{
marginBottom: scaleSize(40),
paddingHorizontal: scaleSize(8),
}}
>
<TVSaveAccountToggle
value={saveAccount}
onValueChange={setSaveAccount}
label={t("save_account.save_for_later")}
disabled={isDisabled}
/>
</View>
{/* Login Button */}
<View style={{ marginBottom: scaleSize(16) }}>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim() || loading}
color='white'
>
{t("login.login_button")}
</Button>
</View>
{/* Quick Connect Button */}
<Button
onPress={onQuickConnect}
color='black'
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</ScrollView>
);
};

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -14,7 +14,6 @@ import {
} from "react-native"; } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv"; import { useTVFocusAnimation } from "@/components/tv";
import { scaleSize } from "@/utils/scaleSize";
interface TVPasswordEntryModalProps { interface TVPasswordEntryModalProps {
visible: boolean; visible: boolean;
@@ -48,35 +47,31 @@ const TVSubmitButton: React.FC<{
animatedStyle, animatedStyle,
{ {
backgroundColor: focused backgroundColor: focused
? "#fff" ? "#a855f7"
: isDisabled : isDisabled
? "#4a4a4a" ? "#4a4a4a"
: "rgba(255,255,255,0.15)", : "#7c3aed",
paddingHorizontal: scaleSize(24), paddingHorizontal: 24,
paddingVertical: scaleSize(14), paddingVertical: 14,
borderRadius: scaleSize(10), borderRadius: 10,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
gap: scaleSize(8), gap: 8,
minWidth: scaleSize(120), minWidth: 120,
opacity: isDisabled ? 0.5 : 1, opacity: isDisabled ? 0.5 : 1,
}, },
]} ]}
> >
{loading ? ( {loading ? (
<ActivityIndicator size='small' color={focused ? "#000" : "#fff"} /> <ActivityIndicator size='small' color='#fff' />
) : ( ) : (
<> <>
<Ionicons <Ionicons name='log-in-outline' size={20} color='#fff' />
name='log-in-outline'
size={scaleSize(20)}
color={focused ? "#000" : "#fff"}
/>
<Text <Text
style={{ style={{
fontSize: scaleSize(16), fontSize: 16,
color: focused ? "#000" : "#fff", color: "#fff",
fontWeight: "600", fontWeight: "600",
}} }}
> >
@@ -122,11 +117,11 @@ const TVPasswordInput: React.FC<{
animatedStyle, animatedStyle,
{ {
backgroundColor: "#1F2937", backgroundColor: "#1F2937",
borderRadius: scaleSize(12), borderRadius: 12,
borderWidth: scaleSize(2), borderWidth: 2,
borderColor: focused ? "#fff" : "#374151", borderColor: focused ? "#6366F1" : "#374151",
paddingHorizontal: scaleSize(16), paddingHorizontal: 16,
paddingVertical: scaleSize(14), paddingVertical: 14,
}, },
]} ]}
> >
@@ -141,7 +136,7 @@ const TVPasswordInput: React.FC<{
autoCorrect={false} autoCorrect={false}
style={{ style={{
color: "#fff", color: "#fff",
fontSize: scaleSize(18), fontSize: 18,
}} }}
onSubmitEditing={onSubmitEditing} onSubmitEditing={onSubmitEditing}
returnKeyType='done' returnKeyType='done'
@@ -250,16 +245,14 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
{/* Password Input */} {/* Password Input */}
{isReady && ( {isReady && (
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<Text style={styles.inputLabel}> <Text style={styles.inputLabel}>{t("login.password")}</Text>
{t("login.password_placeholder")}
</Text>
<TVPasswordInput <TVPasswordInput
value={password} value={password}
onChangeText={(text) => { onChangeText={(text) => {
setPassword(text); setPassword(text);
setError(null); setError(null);
}} }}
placeholder={t("login.password_placeholder")} placeholder={t("login.password")}
onSubmitEditing={handleSubmit} onSubmitEditing={handleSubmit}
hasTVPreferredFocus hasTVPreferredFocus
/> />
@@ -300,45 +293,45 @@ const styles = StyleSheet.create({
width: "100%", width: "100%",
}, },
blurContainer: { blurContainer: {
borderTopLeftRadius: scaleSize(24), borderTopLeftRadius: 24,
borderTopRightRadius: scaleSize(24), borderTopRightRadius: 24,
overflow: "hidden", overflow: "hidden",
}, },
content: { content: {
paddingTop: scaleSize(24), paddingTop: 24,
paddingBottom: scaleSize(50), paddingBottom: 50,
overflow: "visible", overflow: "visible",
}, },
header: { header: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
marginBottom: scaleSize(24), marginBottom: 24,
}, },
title: { title: {
fontSize: scaleSize(28), fontSize: 28,
fontWeight: "bold", fontWeight: "bold",
color: "#fff", color: "#fff",
marginBottom: scaleSize(4), marginBottom: 4,
}, },
subtitle: { subtitle: {
fontSize: scaleSize(16), fontSize: 16,
color: "rgba(255,255,255,0.6)", color: "rgba(255,255,255,0.6)",
}, },
inputContainer: { inputContainer: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
marginBottom: scaleSize(20), marginBottom: 20,
}, },
inputLabel: { inputLabel: {
fontSize: scaleSize(14), fontSize: 14,
color: "rgba(255,255,255,0.6)", color: "rgba(255,255,255,0.6)",
marginBottom: scaleSize(8), marginBottom: 8,
}, },
errorText: { errorText: {
color: "#ef4444", color: "#ef4444",
fontSize: scaleSize(14), fontSize: 14,
marginTop: scaleSize(8), marginTop: 8,
}, },
buttonContainer: { buttonContainer: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
alignItems: "flex-start", alignItems: "flex-start",
}, },
}); });

View File

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

View File

@@ -1,115 +0,0 @@
import { t } from "i18next";
import React, { useCallback } from "react";
import { View } from "react-native";
import QRCode from "react-native-qrcode-svg";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
interface TVQRCodeDisplayProps {
code: string;
onBack?: () => void;
}
export const TVQRCodeDisplay: React.FC<TVQRCodeDisplayProps> = ({
code,
onBack,
}) => {
const typography = useScaledTVTypography();
const qrSize = scaleSize(280);
const cardPadding = scaleSize(16);
const sectionPadding = scaleSize(32);
const outerPadding = scaleSize(60);
const qrData = JSON.stringify({
action: "streamyfin-pair",
code,
});
const handleBack = useCallback(() => {
if (!onBack) return false;
onBack();
return true;
}, [onBack]);
useTVBackPress(() => handleBack(), [handleBack]);
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: outerPadding,
}}
>
{/* QR Code */}
<View
style={{
alignItems: "center",
paddingVertical: sectionPadding,
paddingHorizontal: cardPadding,
borderRadius: scaleSize(16),
backgroundColor: "rgba(255, 255, 255, 0.05)",
}}
>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: scaleSize(8),
}}
>
{t("pairing.waiting_for_phone")}
</Text>
<View
style={{
padding: cardPadding,
borderRadius: scaleSize(12),
backgroundColor: "#FFFFFF",
}}
>
<QRCode
value={qrData}
size={qrSize}
color='#000000'
backgroundColor='#FFFFFF'
/>
</View>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
letterSpacing: scaleSize(8),
marginTop: scaleSize(16),
}}
>
{code}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: scaleSize(8),
}}
>
{t("pairing.scan_with_phone")}
</Text>
</View>
</View>
</View>
);
};

View File

@@ -14,7 +14,6 @@ import {
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput"; import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
import { TVOptionCard, useTVFocusAnimation } from "@/components/tv"; import { TVOptionCard, useTVFocusAnimation } from "@/components/tv";
import { scaleSize } from "@/utils/scaleSize";
import type { AccountSecurityType } from "@/utils/secureCredentials"; import type { AccountSecurityType } from "@/utils/secureCredentials";
interface TVSaveAccountModalProps { interface TVSaveAccountModalProps {
@@ -76,29 +75,25 @@ const TVSaveButton: React.FC<{
animatedStyle, animatedStyle,
{ {
backgroundColor: focused backgroundColor: focused
? "#fff" ? "#a855f7"
: disabled : disabled
? "#4a4a4a" ? "#4a4a4a"
: "rgba(255,255,255,0.15)", : "#7c3aed",
paddingHorizontal: scaleSize(24), paddingHorizontal: 24,
paddingVertical: scaleSize(14), paddingVertical: 14,
borderRadius: scaleSize(10), borderRadius: 10,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: scaleSize(8), gap: 8,
opacity: disabled ? 0.5 : 1, opacity: disabled ? 0.5 : 1,
}, },
]} ]}
> >
<Ionicons <Ionicons name='checkmark' size={20} color='#fff' />
name='checkmark'
size={scaleSize(20)}
color={focused ? "#000" : "#fff"}
/>
<Text <Text
style={{ style={{
fontSize: scaleSize(16), fontSize: 16,
color: focused ? "#000" : "#fff", color: "#fff",
fontWeight: "600", fontWeight: "600",
}} }}
> >
@@ -130,23 +125,23 @@ const TVBackButton: React.FC<{
animatedStyle, animatedStyle,
{ {
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)", backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
paddingHorizontal: scaleSize(20), paddingHorizontal: 20,
paddingVertical: scaleSize(12), paddingVertical: 12,
borderRadius: scaleSize(10), borderRadius: 10,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: scaleSize(8), gap: 8,
}, },
]} ]}
> >
<Ionicons <Ionicons
name='chevron-back' name='chevron-back'
size={scaleSize(20)} size={20}
color={focused ? "#000" : "rgba(255,255,255,0.8)"} color={focused ? "#000" : "rgba(255,255,255,0.8)"}
/> />
<Text <Text
style={{ style={{
fontSize: scaleSize(16), fontSize: 16,
color: focused ? "#000" : "rgba(255,255,255,0.8)", color: focused ? "#000" : "rgba(255,255,255,0.8)",
fontWeight: "500", fontWeight: "500",
}} }}
@@ -379,35 +374,35 @@ const styles = StyleSheet.create({
width: "100%", width: "100%",
}, },
blurContainer: { blurContainer: {
borderTopLeftRadius: scaleSize(24), borderTopLeftRadius: 24,
borderTopRightRadius: scaleSize(24), borderTopRightRadius: 24,
overflow: "hidden", overflow: "hidden",
}, },
content: { content: {
paddingTop: scaleSize(24), paddingTop: 24,
paddingBottom: scaleSize(50), paddingBottom: 50,
overflow: "visible", overflow: "visible",
}, },
header: { header: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
marginBottom: scaleSize(20), marginBottom: 20,
}, },
title: { title: {
fontSize: scaleSize(28), fontSize: 28,
fontWeight: "bold", fontWeight: "bold",
color: "#fff", color: "#fff",
marginBottom: scaleSize(4), marginBottom: 4,
}, },
subtitle: { subtitle: {
fontSize: scaleSize(16), fontSize: 16,
color: "rgba(255,255,255,0.6)", color: "rgba(255,255,255,0.6)",
}, },
sectionTitle: { sectionTitle: {
fontSize: scaleSize(16), fontSize: 16,
fontWeight: "500", fontWeight: "500",
color: "rgba(255,255,255,0.6)", color: "rgba(255,255,255,0.6)",
marginBottom: scaleSize(16), marginBottom: 16,
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: 1, letterSpacing: 1,
}, },
@@ -415,26 +410,26 @@ const styles = StyleSheet.create({
overflow: "visible", overflow: "visible",
}, },
scrollContent: { scrollContent: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
paddingVertical: scaleSize(10), paddingVertical: 10,
gap: scaleSize(12), gap: 12,
}, },
buttonRow: { buttonRow: {
marginTop: scaleSize(20), marginTop: 20,
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
flexDirection: "row", flexDirection: "row",
gap: scaleSize(16), gap: 16,
alignItems: "center", alignItems: "center",
}, },
pinContainer: { pinContainer: {
paddingHorizontal: scaleSize(48), paddingHorizontal: 48,
alignItems: "center", alignItems: "center",
marginBottom: scaleSize(10), marginBottom: 10,
}, },
errorText: { errorText: {
color: "#ef4444", color: "#ef4444",
fontSize: scaleSize(14), fontSize: 14,
marginTop: scaleSize(12), marginTop: 12,
textAlign: "center", textAlign: "center",
}, },
}); });

View File

@@ -1,7 +1,7 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, View } from "react-native"; import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize"; import { Colors } from "@/constants/Colors";
interface TVSaveAccountToggleProps { interface TVSaveAccountToggleProps {
value: boolean; value: boolean;
@@ -62,7 +62,7 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
style={[ style={[
{ {
transform: [{ scale }], transform: [{ scale }],
shadowColor: "#fff", shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowRadius: 16, shadowRadius: 16,
elevation: 8, elevation: 8,
@@ -75,9 +75,9 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a", backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 2, borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent", borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: scaleSize(16), borderRadius: 16,
paddingHorizontal: scaleSize(24), paddingHorizontal: 24,
paddingVertical: scaleSize(20), paddingVertical: 20,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
@@ -85,7 +85,7 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
> >
<Text <Text
style={{ style={{
fontSize: scaleSize(20), fontSize: 20,
color: "#FFFFFF", color: "#FFFFFF",
}} }}
> >
@@ -94,20 +94,20 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
<View <View
pointerEvents='none' pointerEvents='none'
style={{ style={{
width: scaleSize(60), width: 60,
height: scaleSize(34), height: 34,
borderRadius: scaleSize(17), borderRadius: 17,
backgroundColor: value ? "#fff" : "#3f3f46", backgroundColor: value ? Colors.primary : "#3f3f46",
justifyContent: "center", justifyContent: "center",
paddingHorizontal: scaleSize(3), paddingHorizontal: 3,
}} }}
> >
<View <View
style={{ style={{
width: scaleSize(28), width: 28,
height: scaleSize(28), height: 28,
borderRadius: scaleSize(14), borderRadius: 14,
backgroundColor: value ? "#000" : "#fff", backgroundColor: "white",
alignSelf: value ? "flex-end" : "flex-start", alignSelf: value ? "flex-end" : "flex-start",
}} }}
/> />

View File

@@ -0,0 +1,153 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useRef, useState } from "react";
import {
ActivityIndicator,
Animated,
Easing,
Pressable,
View,
} from "react-native";
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<TVServerCardProps> = ({
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 (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isDisabled}
focusable={!isDisabled}
hasTVPreferredFocus={hasTVPreferredFocus && !isDisabled}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
}}
numberOfLines={1}
>
{title}
</Text>
{subtitle && (
<Text
style={{
fontSize: 16,
color: "#9CA3AF",
marginTop: 4,
}}
numberOfLines={1}
>
{subtitle}
</Text>
)}
</View>
<View style={{ marginLeft: 16 }}>
{isLoading ? (
<ActivityIndicator size='small' color={Colors.primary} />
) : securityIcon ? (
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Ionicons
name={securityIcon}
size={20}
color={Colors.primary}
style={{ marginRight: 8 }}
/>
<Ionicons
name='chevron-forward'
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
</View>
) : (
<Ionicons
name='chevron-forward'
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
)}
</View>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -1,212 +0,0 @@
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";
import { scaleSize } from "@/utils/scaleSize";
// 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<View, TVServerIconProps>(
(
{
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 (
<Pressable
ref={ref}
onPress={onPress}
onLongPress={onLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: scaleSize(160),
shadowColor: gradientStart,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.7 : 0,
shadowRadius: focused ? scaleSize(24) : 0,
},
]}
>
<View
style={{
width: scaleSize(140),
height: scaleSize(140),
borderRadius: scaleSize(70),
overflow: "hidden",
marginBottom: scaleSize(14),
borderWidth: focused ? scaleSize(3) : 0,
borderColor: "#fff",
}}
>
<LinearGradient
colors={[gradientStart, gradientEnd]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
opacity: focused ? 1 : 0.85,
}}
>
<Text
style={{
fontSize: scaleSize(48),
fontWeight: "bold",
color: "#fff",
textShadowColor: "rgba(0,0,0,0.3)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
}}
>
{initial}
</Text>
</LinearGradient>
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
marginBottom: scaleSize(4),
}}
numberOfLines={3}
>
{displayName}
</Text>
{name && (
<Text
style={{
fontSize: typography.callout,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
}}
numberOfLines={3}
>
{address.replace(/^https?:\/\//, "")}
</Text>
)}
</Animated.View>
</Pressable>
);
},
);

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