Merge origin/develop into refactor-chromecast

Bring 323 commits of develop (incl. the Expo SDK 56 / TV-branch work) into
the chromecast refactor. Conflict resolutions:

- chapters: take develop's reviewed version (ChapterList/ChapterTicks/
  chapters.ts/test) — adds chapterNameAt, markers API, themed Colors.
- auto-skip: keep chromecast's unified useSegmentSkipper for the phone
  player; restore develop's useCreditSkipper/useIntroSkipper (deleted on
  chromecast) so develop's Controls.tv.tsx compiles. TV->useSegmentSkipper
  migration left as follow-up.
- en.json: union the two player blocks (kept chromecast casting keys +
  develop's subtitle/playback keys).
- TechnicalInfoOverlay/PlatformDropdown: take develop's TV-safe versions
  (kept chromecast's disabled-prop branch, aliased to avoid shadowing the
  @expo/ui disabled modifier).
- SDK 56 fixes: expo-router Router -> ImperativeRouter in cast components;
  ChapterTicks markers API in CastPlayerProgressBar.
- restore utils/profiles/chromecast* (deleted on chromecast, still used by
  PlayButton).

Typecheck passes; bun.lock regenerated against merged package.json.
This commit is contained in:
Gauvain
2026-06-01 23:14:35 +02:00
435 changed files with 48123 additions and 6489 deletions

436
docs/research/hdr-mpv.md Normal file
View File

@@ -0,0 +1,436 @@
# HDR Support on tvOS with mpv - Research Document
## Problem Statement
HDR content appears washed out on Apple TV when using the mpv-based player. The TV doesn't show an HDR indicator and colors look flat compared to other apps like Infuse.
**Key Discovery:** HDR works correctly on iPhone but not on tvOS, despite using the same mpv player.
---
## Why HDR Works on iPhone
In `MpvPlayerView.swift`:
```swift
#if !os(tvOS)
if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#endif
```
On iOS 17+, setting `wantsExtendedDynamicRangeContent = true` on `AVSampleBufferDisplayLayer` enables Extended Dynamic Range (EDR). This tells the display layer to preserve HDR metadata and render in high dynamic range.
**This API does not exist on tvOS.** Attempting to use it results in:
> 'wantsExtendedDynamicRangeContent' is unavailable in tvOS
tvOS uses a different HDR architecture designed for external displays via HDMI.
---
## tvOS HDR Architecture
Unlike iPhone (integrated display), Apple TV connects to external TVs. Apple expects apps to:
1. **Use `AVDisplayCriteria`** to request display mode changes
2. **Attach proper colorspace metadata** to pixel buffers
3. **Let the TV handle HDR rendering** via HDMI passthrough
This is how Netflix, Infuse, and the TV app work - they signal "I'm playing HDR10 at 24fps" and tvOS switches the TV to that mode.
---
## MPVKit vo_avfoundation Analysis
**Location:** `/MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch`
### Existing HDR Infrastructure
The driver has comprehensive HDR support already built in:
#### 1. HDR Metadata Copy Function (lines 253-270)
```c
static void copy_hdr_metadata(CVPixelBufferRef src, CVPixelBufferRef dst)
{
const CFStringRef keys[] = {
kCVImageBufferTransferFunctionKey, // PQ for HDR10, HLG for HLG
kCVImageBufferColorPrimariesKey, // BT.2020 for HDR
kCVImageBufferYCbCrMatrixKey,
kCVImageBufferMasteringDisplayColorVolumeKey, // HDR10 static metadata
kCVImageBufferContentLightLevelInfoKey, // MaxCLL, MaxFALL
};
for (size_t i = 0; i < MP_ARRAY_SIZE(keys); i++) {
CFTypeRef value = CVBufferGetAttachment(src, keys[i], NULL);
if (value) {
CVBufferSetAttachment(dst, keys[i], value, kCVAttachmentMode_ShouldPropagate);
}
}
}
```
#### 2. 10-bit HDR Format Support (lines 232-247)
```c
// For 10-bit HDR content (P010), use RGBA half-float to preserve HDR precision
if (format == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange ||
format == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange) {
outputFormat = kCVPixelFormatType_64RGBAHalf;
}
```
#### 3. HDR-Safe GPU Compositing (lines 694-695)
```c
CGColorSpaceRef workingColorSpace = CGColorSpaceCreateWithName(
kCGColorSpaceExtendedLinearDisplayP3);
```
### The Problem: Metadata Not Attached in Main Code Path
**Critical Finding:** `copy_hdr_metadata()` is only called during OSD compositing (line 609-610):
```c
// In composite mode, render OSD and composite onto frame
if (p->composite_osd) {
render_osd(vo, pts);
CVPixelBufferRef composited = composite_frame(vo, pixbuf);
// copy_hdr_metadata() called inside composite_frame()
}
```
If `composite_osd` is false (default), **HDR metadata is never attached**.
### Frame Flow Analysis
```
draw_frame() called
├─► Hardware decoded (IMGFMT_VIDEOTOOLBOX)
│ └─► pixbuf = mpi->planes[3] // Direct from VideoToolbox
│ └─► Metadata SHOULD be attached by decoder, but not verified
└─► Software decoded (NV12, 420P, P010)
└─► upload_software_frame()
└─► Creates new CVPixelBuffer
└─► Copies pixel data only
└─► ❌ NO colorspace metadata attached!
CMVideoFormatDescriptionCreateForImageBuffer(finalBuffer)
└─► Format description created FROM pixel buffer
└─► If buffer lacks HDR metadata, format won't have it
[displayLayer enqueueSampleBuffer:buf]
└─► Sent to display layer without HDR signal
```
---
## Root Cause Summary
| Issue | Impact |
|-------|--------|
| `wantsExtendedDynamicRangeContent` unavailable on tvOS | Can't use iOS EDR approach |
| `copy_hdr_metadata()` only runs during OSD compositing | Main playback path skips HDR metadata |
| Software decoded frames get no colorspace attachments | mpv knows colorspace but doesn't pass it to pixel buffer |
| VideoToolbox metadata not verified | May or may not have HDR attachments |
---
## mp_image Colorspace Structures
mpv uses libplacebo's colorspace structures. Here's how colorspace info flows:
### Structure Hierarchy
```
mp_image (video/mp_image.h)
└─► params: mp_image_params
└─► color: pl_color_space
│ ├─► primaries: pl_color_primaries (BT.2020, etc.)
│ ├─► transfer: pl_color_transfer (PQ, HLG, etc.)
│ └─► hdr: pl_hdr_metadata (MaxCLL, MaxFALL, etc.)
└─► repr: pl_color_repr
├─► sys: pl_color_system (BT.2100_PQ, etc.)
└─► levels: pl_color_levels (TV/Full range)
```
### Key Enums
#### Color Primaries (`enum pl_color_primaries`)
```c
PL_COLOR_PRIM_UNKNOWN = 0,
PL_COLOR_PRIM_BT_709, // HD/SDR standard
PL_COLOR_PRIM_BT_2020, // UHD/HDR wide gamut ← HDR
PL_COLOR_PRIM_DCI_P3, // DCI P3 (cinema)
PL_COLOR_PRIM_DISPLAY_P3, // Display P3 (Apple)
// ... more
```
#### Transfer Functions (`enum pl_color_transfer`)
```c
PL_COLOR_TRC_UNKNOWN = 0,
PL_COLOR_TRC_BT_1886, // SDR gamma
PL_COLOR_TRC_SRGB, // sRGB
PL_COLOR_TRC_PQ, // SMPTE 2084 PQ (HDR10/DolbyVision) ← HDR
PL_COLOR_TRC_HLG, // ITU-R BT.2100 HLG ← HDR
// ... more
```
#### Color Systems (`enum pl_color_system`)
```c
PL_COLOR_SYSTEM_BT_709, // HD/SDR
PL_COLOR_SYSTEM_BT_2020_NC, // UHD (non-constant luminance)
PL_COLOR_SYSTEM_BT_2100_PQ, // HDR10 ← HDR
PL_COLOR_SYSTEM_BT_2100_HLG, // HLG ← HDR
PL_COLOR_SYSTEM_DOLBYVISION, // Dolby Vision ← HDR
// ... more
```
### HDR Metadata Structure (`struct pl_hdr_metadata`)
```c
struct pl_hdr_metadata {
struct pl_raw_primaries prim; // CIE xy primaries
float min_luma, max_luma; // Luminance range (cd/m²)
float max_cll; // Maximum Content Light Level
float max_fall; // Maximum Frame-Average Light Level
// ... more
};
```
### Accessing Colorspace in vo_avfoundation
```c
// In draw_frame():
struct mp_image *mpi = frame->current;
// Color primaries
enum pl_color_primaries prim = mpi->params.color.primaries;
// Transfer function
enum pl_color_transfer trc = mpi->params.color.transfer;
// HDR metadata
struct pl_hdr_metadata hdr = mpi->params.color.hdr;
// HDR detection
bool is_hdr = (trc == PL_COLOR_TRC_PQ || trc == PL_COLOR_TRC_HLG);
bool is_wide_gamut = (prim == PL_COLOR_PRIM_BT_2020);
```
---
## The Fix
### Required Changes in vo_avfoundation
**File:** `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch`
**Location:** After line 821 in `draw_frame()`, before the sample buffer is created.
#### Add Required Include
```c
#include "video/csputils.h" // For pl_color_* enums (if not already included)
```
#### Add HDR Metadata Attachment Function
```c
// Add after copy_hdr_metadata() function (around line 270)
static void attach_hdr_metadata(struct vo *vo, CVPixelBufferRef pixbuf,
struct mp_image *mpi)
{
enum pl_color_primaries prim = mpi->params.color.primaries;
enum pl_color_transfer trc = mpi->params.color.transfer;
// Attach BT.2020 color primaries (HDR wide color gamut)
if (prim == PL_COLOR_PRIM_BT_2020) {
CVBufferSetAttachment(pixbuf, kCVImageBufferColorPrimariesKey,
kCVImageBufferColorPrimaries_ITU_R_2020,
kCVAttachmentMode_ShouldPropagate);
CVBufferSetAttachment(pixbuf, kCVImageBufferYCbCrMatrixKey,
kCVImageBufferYCbCrMatrix_ITU_R_2020,
kCVAttachmentMode_ShouldPropagate);
MP_VERBOSE(vo, "HDR: Attached BT.2020 color primaries\n");
}
// Attach PQ transfer function (HDR10/Dolby Vision)
if (trc == PL_COLOR_TRC_PQ) {
CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey,
kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ,
kCVAttachmentMode_ShouldPropagate);
MP_VERBOSE(vo, "HDR: Attached PQ transfer function (HDR10)\n");
}
// Attach HLG transfer function
else if (trc == PL_COLOR_TRC_HLG) {
CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey,
kCVImageBufferTransferFunction_ITU_R_2100_HLG,
kCVAttachmentMode_ShouldPropagate);
MP_VERBOSE(vo, "HDR: Attached HLG transfer function\n");
}
// Attach HDR static metadata if available
struct pl_hdr_metadata hdr = mpi->params.color.hdr;
if (hdr.max_cll > 0 || hdr.max_fall > 0) {
// ContentLightLevelInfo is a 4-byte structure:
// - 2 bytes: MaxCLL (max content light level)
// - 2 bytes: MaxFALL (max frame-average light level)
uint16_t cll_data[2] = {
(uint16_t)fminf(hdr.max_cll, 65535.0f),
(uint16_t)fminf(hdr.max_fall, 65535.0f)
};
CFDataRef cllInfo = CFDataCreate(NULL, (const UInt8 *)cll_data, sizeof(cll_data));
if (cllInfo) {
CVBufferSetAttachment(pixbuf, kCVImageBufferContentLightLevelInfoKey,
cllInfo, kCVAttachmentMode_ShouldPropagate);
CFRelease(cllInfo);
MP_VERBOSE(vo, "HDR: Attached CLL metadata (MaxCLL=%d, MaxFALL=%d)\n",
cll_data[0], cll_data[1]);
}
}
}
```
#### Call the Function in draw_frame()
```c
// In draw_frame(), after line 821 (after getting pixbuf), add:
// Attach HDR colorspace metadata to pixel buffer
// This ensures the display layer receives proper HDR signaling
attach_hdr_metadata(vo, pixbuf, mpi);
```
### Complete draw_frame() Modification
The modified section should look like:
```c
CVPixelBufferRef pixbuf = NULL;
bool pixbufNeedsRelease = false;
// Handle different input formats
if (mpi->imgfmt == IMGFMT_VIDEOTOOLBOX) {
// Hardware decoded: zero-copy passthrough
pixbuf = (CVPixelBufferRef)mpi->planes[3];
} else {
// Software decoded: upload to CVPixelBuffer
pixbuf = upload_software_frame(vo, mpi);
if (!pixbuf) {
MP_ERR(vo, "Failed to upload software frame\n");
mp_image_unrefp(&mpi);
return false;
}
pixbufNeedsRelease = true;
}
// >>> NEW: Attach HDR colorspace metadata <<<
attach_hdr_metadata(vo, pixbuf, mpi);
CVPixelBufferRef finalBuffer = pixbuf;
bool needsRelease = false;
// ... rest of the function
```
---
## Alternative Solutions
### Option A: Enable composite_osd Mode (Quick Test)
Since `copy_hdr_metadata()` works in composite mode, try enabling it:
```
--avfoundation-composite-osd=yes
```
This would trigger the existing HDR metadata path. Downside: OSD compositing has performance overhead.
### Option B: Full vo_avfoundation Fix (Recommended)
Modify the driver to always attach colorspace metadata based on `mp_image` params. This is the implementation described above.
### Option C: Dual Player Approach
Use AVPlayer for HDR content, mpv for everything else. This is what Swiftfin does.
---
## Implementation Checklist
- [ ] Clone MPVKit fork
- [ ] Modify `0004-avfoundation-video-output.patch`:
- [ ] Add `attach_hdr_metadata()` function
- [ ] Call it in `draw_frame()` after getting pixbuf
- [ ] Add necessary includes if needed
- [ ] Rebuild MPVKit
- [ ] Test with HDR10 content on tvOS
- [ ] Verify TV shows HDR indicator
- [ ] Test with HLG content
- [ ] Test with Dolby Vision content (may need additional work)
---
## Current Implementation Status
**What's implemented in Streamyfin:**
1. **HDR Detection** (`MPVLayerRenderer.swift`)
- Reads `video-params/primaries` and `video-params/gamma` from mpv
- Detects HDR10 (bt.2020 + pq), HLG, Dolby Vision
2. **AVDisplayCriteria** (`MpvPlayerView.swift`)
- Sets `preferredDisplayCriteria` on tvOS 17.0+ when HDR detected
- Creates CMFormatDescription with HDR color extensions
3. **target-colorspace-hint** (`MPVLayerRenderer.swift`)
- Added `target-colorspace-hint=yes` for tvOS
**What's NOT working:**
- TV doesn't show HDR indicator
- Colors appear washed out
- The pixel buffers lack HDR metadata attachments ← **This is what the fix addresses**
---
## Industry Context
| Project | Player | HDR Status |
|---------|--------|------------|
| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, uses AVPlayer for HDR |
| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv | No HDR support |
| Infuse | Custom Metal engine | Works correctly |
**Key insight:** No mpv-based player has solved HDR on tvOS. This fix could be a first.
---
## Technical References
### Apple Documentation
- [AVDisplayManager](https://developer.apple.com/documentation/avkit/avdisplaymanager)
- [AVDisplayCriteria](https://developer.apple.com/documentation/avkit/avdisplaycriteria)
- [WWDC22: Display HDR video in EDR](https://developer.apple.com/videos/play/wwdc2022/110565/)
### CVImageBuffer Keys
- `kCVImageBufferColorPrimariesKey` - Color gamut (BT.709, BT.2020, P3)
- `kCVImageBufferTransferFunctionKey` - Transfer function (sRGB, PQ, HLG)
- `kCVImageBufferYCbCrMatrixKey` - YCbCr conversion matrix
- `kCVImageBufferMasteringDisplayColorVolumeKey` - Mastering display metadata
- `kCVImageBufferContentLightLevelInfoKey` - MaxCLL/MaxFALL
### mpv/libplacebo Source
- mp_image struct: `video/mp_image.h`
- Colorspace enums: libplacebo `pl_color.h`
- vo_avfoundation: `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch`
### Key Functions in vo_avfoundation
| Function | Line | Purpose |
|----------|------|---------|
| `draw_frame()` | 781 | Main frame rendering |
| `copy_hdr_metadata()` | 253 | Copy HDR metadata between buffers |
| `upload_software_frame()` | 295 | Upload SW frames to CVPixelBuffer |
| `composite_frame()` | 582 | OSD compositing with HDR support |

136
docs/tv-discovery.md Normal file
View File

@@ -0,0 +1,136 @@
# TV Discovery
This document explains Streamyfin's platform-specific home screen discovery integrations for Apple TV and Android TV.
## Overview
Streamyfin currently publishes the same "Continue and Next Up" content to two different platform surfaces:
- `tvOS`: Apple TV Top Shelf
- `Android TV`: preview channel recommendations
Both integrations are fed by the same shared payload builder in [utils/tvDiscovery/payload.ts](../utils/tvDiscovery/payload.ts).
## Shared Data Flow
The TV home screen data starts in [components/home/Home.tv.tsx](../components/home/Home.tv.tsx), where the app fetches resume and next-up items and passes them into [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts).
The sync layer:
- builds a normalized TV discovery payload
- sends it to the tvOS Top Shelf cache writer on Apple TV
- sends it to the Android TV recommendations module on Android TV
- clears published content when server or user state changes
## Apple TV Top Shelf
Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files:
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
How it works:
- the app builds a lightweight JSON payload
- the app stores that payload in the shared app group container
- the tvOS Top Shelf extension reads the cached payload
- the extension renders sections and items for Top Shelf
Why the API key is stored on tvOS:
- the Top Shelf extension runs outside the app process
- it may need authenticated image access when loading poster artwork
- the app stores the API key so the extension can build authenticated requests
## Android TV Recommendations
Android TV uses the TV provider APIs to publish a preview channel and preview programs.
Relevant files:
- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt)
- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt)
- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt)
- [modules/tv-recommendations/android/src/main/AndroidManifest.xml](../modules/tv-recommendations/android/src/main/AndroidManifest.xml)
- [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts)
How it works:
- the app builds the shared TV discovery payload
- the Android native module creates or updates a single preview channel
- the module inserts or updates preview programs for each item
- the module stores the last payload in shared preferences
- the `INITIALIZE_PROGRAMS` receiver can replay the cached payload when requested by the system
Important differences from tvOS:
- Android TV does not use a separate extension target
- Android TV content is persisted through `TvContractCompat`
- artwork is currently published as poster URLs, not app-proxied local content
## Logging
### JavaScript logs
Look for `TVDiscovery` in Metro or app logs.
Examples:
- payload prepared
- Android sync result
- clear operations
### Native Android logs
Use `adb logcat | grep TvRecommendations`
Examples:
- channel created or updated
- preview programs inserted or updated
- stale programs deleted
- cached payload replayed
## Verifying Android TV Output
1. Launch the TV build and let the home screen load.
2. Watch `adb logcat | grep TvRecommendations`.
3. Return to the Android TV / Google TV home screen.
4. Look for the `Continue and Next Up` row.
5. If needed, enable the Streamyfin channel in `Customize home` or `Manage channels`.
Note:
- some launchers delay or hide new preview channels
- some devices expose TV provider data per user/profile
## Build Notes
This feature does not currently require a fresh `prebuild` to work in the checked-in Android project.
Why:
- the Android integration is a local Expo module
- its receiver is declared in the module manifest
- Gradle merges it during normal Android TV builds
Typical commands:
- `bun run android:tv`
- `bun run ios:tv`
## Current Limitations
- Android TV artwork may fail on authenticated Jellyfin servers because the launcher fetches poster URLs outside the app
- Android TV currently publishes a preview channel only, not Watch Next
- tvOS and Android TV both use the same payload source, so section selection is shared unless explicitly split later
## Future Improvements
- add a local image proxy or cache for Android TV artwork
- add Watch Next support for resumable content
- add a native debug dump method for querying TV provider state from inside the app process

305
docs/tv-focus-guide.md Normal file
View File

@@ -0,0 +1,305 @@
# TV Focus Guide Navigation
This document explains how to use `TVFocusGuideView` to create reliable focus navigation between non-adjacent sections on Apple TV and Android TV.
## Platform Differences (CRITICAL)
### tvOS vs Android TV
**`nextFocusUp`, `nextFocusDown`, `nextFocusLeft`, `nextFocusRight` props only work on Android TV, NOT tvOS.**
This is a [known limitation](https://github.com/react-native-tvos/react-native-tvos/issues/490). These props are documented as "only for Android" in React Native.
```typescript
// ❌ Does NOT work on tvOS (Apple TV)
<Pressable nextFocusUp={someNodeHandle} nextFocusDown={anotherNodeHandle}>
...
</Pressable>
// ✅ Works on both tvOS and Android TV
<TVFocusGuideView destinations={[targetRef]}>
...
</TVFocusGuideView>
```
**For tvOS, always use `TVFocusGuideView` with the `destinations` prop.**
## ScrollView vs FlatList for TV
**Use ScrollView instead of FlatList for horizontal lists on TV when focus navigation is critical.**
FlatList only renders visible items and manages its own recycling, which can interfere with focus navigation. ScrollView renders all items at once, providing more predictable focus behavior.
```typescript
// ❌ FlatList can cause focus issues on TV
<FlatList
horizontal
data={cast}
renderItem={({ item, index }) => <CastCard ... />}
/>
// ✅ ScrollView provides reliable focus navigation
<ScrollView horizontal>
{cast.map((person, index) => (
<CastCard key={person.id} ... />
))}
</ScrollView>
```
**When to use which:**
- **ScrollView**: Small to medium lists (< 20 items) where focus navigation must be reliable
- **FlatList**: Large lists where performance is more important than perfect focus navigation
## The Problem
tvOS uses a **geometric focus engine** that draws a ray in the navigation direction and finds the nearest focusable element. This works well for adjacent elements but fails when:
- Sections are not geometrically aligned (e.g., left-aligned buttons above a horizontally-scrolling list)
- Lists are long and the "nearest" element is in the middle rather than the first item
- There's empty space between focusable sections
**Symptoms:**
- Focus lands in the middle of a list instead of the first item
- Can't navigate down to a section at all
- Focus jumps to unexpected elements
## The Solution: TVFocusGuideView with destinations
`TVFocusGuideView` is a React Native component that creates an invisible focus region. When combined with the `destinations` prop, it redirects focus to specific elements.
### Basic Pattern
```typescript
import { TVFocusGuideView, View } from "react-native";
// 1. Track the destination element with state (NOT useRef!)
const [targetRef, setTargetRef] = useState<View | null>(null);
// 2. Place an invisible focus guide between sections
{targetRef && (
<TVFocusGuideView
destinations={[targetRef]}
style={{ height: 1, width: "100%" }}
/>
)}
// 3. Pass the state setter as a callback ref to the target
<TargetComponent ref={setTargetRef} />
```
### Why useState Instead of useRef?
The focus guide only updates when it receives a prop change. Using `useRef` won't trigger re-renders when the ref is set, so the focus guide won't know about the destination. **Always use `useState`** to track refs for focus guides.
```typescript
// ❌ Won't work - useRef doesn't trigger re-renders
const targetRef = useRef<View>(null);
<TVFocusGuideView destinations={targetRef.current ? [targetRef.current] : []} />
// ✅ Works - useState triggers re-render when ref is set
const [targetRef, setTargetRef] = useState<View | null>(null);
<TVFocusGuideView destinations={targetRef ? [targetRef] : []} />
```
## Bidirectional Navigation (CRITICAL PATTERN)
When you need focus to navigate both UP and DOWN between sections, you must stack both focus guides together AND avoid `hasTVPreferredFocus` on the destination element.
### The Focus Flickering Problem
If you use `hasTVPreferredFocus={true}` on an element that is ALSO the destination of a focus guide, you will get **focus flickering** where focus rapidly jumps back and forth between elements.
```typescript
// ❌ CAUSES FOCUS FLICKERING - destination has hasTVPreferredFocus
<TVFocusGuideView destinations={[firstCardRef]} />
<ScrollView horizontal>
{items.map((item, index) => (
<Card
ref={index === 0 ? setFirstCardRef : undefined}
hasTVPreferredFocus={index === 0} // ❌ DON'T DO THIS
/>
))}
</ScrollView>
// ✅ CORRECT - destination does NOT have hasTVPreferredFocus
<TVFocusGuideView destinations={[firstCardRef]} />
<ScrollView horizontal>
{items.map((item, index) => (
<Card
ref={index === 0 ? setFirstCardRef : undefined}
// No hasTVPreferredFocus - the focus guide handles directing focus here
/>
))}
</ScrollView>
```
### Complete Bidirectional Example
```typescript
const MyScreen: React.FC = () => {
// Track refs for focus navigation
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [firstCastCardRef, setFirstCastCardRef] = useState<View | null>(null);
return (
<View style={{ flex: 1 }}>
{/* Action buttons section */}
<View style={{ flexDirection: "row", gap: 16 }}>
<TVButton
ref={setPlayButtonRef}
onPress={handlePlay}
hasTVPreferredFocus // OK here - this is NOT a focus guide destination
>
Play
</TVButton>
</View>
{/* Cast section */}
<View>
<Text>Cast</Text>
{/* BOTH focus guides stacked together, above the list */}
{/* Downward: Play button → first cast card */}
{firstCastCardRef && (
<TVFocusGuideView
destinations={[firstCastCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Upward: cast → Play button */}
{playButtonRef && (
<TVFocusGuideView
destinations={[playButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Use ScrollView, not FlatList, for reliable focus */}
<ScrollView horizontal style={{ overflow: "visible" }}>
{cast.map((person, index) => (
<CastCard
key={person.id}
person={person}
refSetter={index === 0 ? setFirstCastCardRef : undefined}
// ⚠️ NO hasTVPreferredFocus here - causes flickering!
/>
))}
</ScrollView>
</View>
</View>
);
};
```
### Key Rules for Bidirectional Navigation
1. **Stack both focus guides together** - Place them adjacent to each other, above the destination list
2. **Do NOT use `hasTVPreferredFocus` on focus guide destinations** - This causes focus flickering
3. **Use ScrollView instead of FlatList** - More reliable focus behavior
4. **Use `useState` for refs, not `useRef`** - Triggers re-renders when refs are set
## Focus Guide Placement
The focus guides should be placed **together** above the destination section:
```
┌─────────────────────────┐
│ Action Buttons │ ← Source (going down)
│ [Play] [Request] │ Has hasTVPreferredFocus ✓
└─────────────────────────┘
┌─────────────────────────┐
│ TVFocusGuideView │ ← Downward guide
│ destinations=[card1] │
├─────────────────────────┤
│ TVFocusGuideView │ ← Upward guide
│ destinations=[playBtn] │ (stacked together)
└─────────────────────────┘
┌─────────────────────────┐
│ Cast Cards (ScrollView)│ ← First card is destination
│ [👤] [👤] [👤] [👤] │ NO hasTVPreferredFocus ✗
└─────────────────────────┘
```
## Component Pattern with refSetter
For components that need to be focus guide destinations, use a `refSetter` callback prop:
```typescript
interface TVCastCardProps {
person: { id: number; name: string };
onPress: () => void;
refSetter?: (ref: View | null) => void;
}
const TVCastCard: React.FC<TVCastCardProps> = ({
person,
onPress,
refSetter,
}) => {
return (
<Pressable
ref={refSetter}
onPress={onPress}
// No hasTVPreferredFocus when this is a focus guide destination
>
<Text>{person.name}</Text>
</Pressable>
);
};
// Usage
<TVCastCard
person={person}
onPress={handlePress}
refSetter={index === 0 ? setFirstCastCardRef : undefined}
/>
```
## Tips and Gotchas
1. **Guard against null refs**: Only render the focus guide when the ref is set:
```typescript
{targetRef && <TVFocusGuideView destinations={[targetRef]} />}
```
2. **Style the guide invisibly**: Use `height: 1` or `width: 1` to make it invisible but still functional:
```typescript
style={{ height: 1, width: "100%" }}
```
3. **Multiple destinations**: You can provide multiple destinations and the focus engine will pick the geometrically closest one:
```typescript
<TVFocusGuideView destinations={[ref1, ref2, ref3]} />
```
4. **Focus trapping**: Use `trapFocusUp`, `trapFocusDown`, etc. to prevent focus from leaving a region (useful for modals):
```typescript
<TVFocusGuideView trapFocusUp trapFocusDown trapFocusLeft trapFocusRight>
{/* Modal content */}
</TVFocusGuideView>
```
5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child when entering a region:
```typescript
<TVFocusGuideView autoFocus>
{/* First focusable child will receive focus */}
</TVFocusGuideView>
```
**Warning**: Don't use `autoFocus` on a wrapper when you also have bidirectional focus guides - it can interfere with upward navigation.
## Common Mistakes
| Mistake | Result | Fix |
|---------|--------|-----|
| Using `nextFocusUp`/`nextFocusDown` props | Doesn't work on tvOS | Use `TVFocusGuideView` |
| Using FlatList for horizontal lists | Focus navigation unreliable | Use ScrollView |
| `hasTVPreferredFocus` on focus guide destination | Focus flickering loop | Remove `hasTVPreferredFocus` from destination |
| Focus guides placed separately | Focus flickering | Stack both guides together |
| Using `useRef` for focus guide refs | Focus guide doesn't update | Use `useState` |
## Reference Implementation
See `components/jellyseerr/tv/TVJellyseerrPage.tsx` for a complete implementation of bidirectional focus navigation between action buttons and a cast list.

416
docs/tv-modal-guide.md Normal file
View File

@@ -0,0 +1,416 @@
# TV Modal Guide
This document explains how to implement modals, bottom sheets, and overlays on Apple TV and Android TV in React Native.
## The Problem
On TV platforms, modals have unique challenges:
- The hardware back button must work correctly to dismiss modals
- Focus management must be handled explicitly
- React Native's `Modal` component breaks the TV focus chain
- Overlay/absolute-positioned modals don't handle back button correctly
## Navigation-Based Modal Pattern (Recommended)
For modals that need proper back button support, use the **navigation-based modal pattern**. This leverages Expo Router's stack navigation with transparent modal presentation.
### Architecture
```
┌─────────────────────────────────────┐
│ 1. Jotai Atom (state) │
│ Stores modal data/params │
├─────────────────────────────────────┤
│ 2. Hook (trigger) │
│ Sets atom + calls router.push() │
├─────────────────────────────────────┤
│ 3. Page File (UI) │
│ Reads atom, renders modal │
│ Clears atom on unmount │
├─────────────────────────────────────┤
│ 4. Stack.Screen (config) │
│ presentation: transparentModal │
│ animation: fade │
└─────────────────────────────────────┘
```
### Step 1: Create the Atom
Create a Jotai atom to store the modal state/data:
```typescript
// utils/atoms/tvExampleModal.ts
import { atom } from "jotai";
export interface TVExampleModalData {
itemId: string;
title: string;
// ... other data the modal needs
}
export const tvExampleModalAtom = atom<TVExampleModalData | null>(null);
```
### Step 2: Create the Hook
Create a hook that sets the atom and navigates to the modal:
```typescript
// hooks/useTVExampleModal.ts
import { useSetAtom } from "jotai";
import { router } from "expo-router";
import { tvExampleModalAtom, TVExampleModalData } from "@/utils/atoms/tvExampleModal";
export const useTVExampleModal = () => {
const setModalData = useSetAtom(tvExampleModalAtom);
const openModal = (data: TVExampleModalData) => {
setModalData(data);
router.push("/tv-example-modal");
};
return { openModal };
};
```
### Step 3: Create the Modal Page
Create a page file that reads the atom and renders the modal UI:
```typescript
// app/(auth)/tv-example-modal.tsx
import { useEffect } from "react";
import { View, Pressable, Text } from "react-native";
import { useAtom } from "jotai";
import { router } from "expo-router";
import { BlurView } from "expo-blur";
import { tvExampleModalAtom } from "@/utils/atoms/tvExampleModal";
export default function TVExampleModal() {
const [modalData, setModalData] = useAtom(tvExampleModalAtom);
// Clear atom on unmount
useEffect(() => {
return () => {
setModalData(null);
};
}, [setModalData]);
// Handle case where modal is opened without data
if (!modalData) {
router.back();
return null;
}
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
{/* Background overlay */}
<Pressable
style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
}}
onPress={() => router.back()}
/>
{/* Modal content */}
<BlurView
intensity={80}
tint="dark"
style={{
padding: 32,
borderRadius: 16,
minWidth: 400,
}}
>
<Text style={{ color: "#fff", fontSize: 24 }}>
{modalData.title}
</Text>
{/* Modal content here */}
<Pressable
onPress={() => router.back()}
hasTVPreferredFocus
style={({ focused }) => ({
marginTop: 24,
padding: 16,
borderRadius: 8,
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
})}
>
{({ focused }) => (
<Text style={{ color: focused ? "#000" : "#fff" }}>
Close
</Text>
)}
</Pressable>
</BlurView>
</View>
);
}
```
### Step 4: Add Stack.Screen Configuration
Add the modal route to `app/_layout.tsx`:
```typescript
// In app/_layout.tsx, inside your Stack navigator
<Stack.Screen
name="(auth)/tv-example-modal"
options={{
presentation: "transparentModal",
animation: "fade",
headerShown: false,
}}
/>
```
### Usage
```typescript
// In any component
import { useTVExampleModal } from "@/hooks/useTVExampleModal";
const MyComponent = () => {
const { openModal } = useTVExampleModal();
return (
<Pressable
onPress={() => openModal({ itemId: "123", title: "Example" })}
>
<Text>Open Modal</Text>
</Pressable>
);
};
```
### Reference Implementation
See `useTVRequestModal` + `app/(auth)/tv-request-modal.tsx` for a complete working example.
---
## Bottom Sheet Pattern (Inline Overlays)
For simpler overlays that don't need back button navigation (like option selectors), use an **inline absolute-positioned overlay**. This pattern is ideal for:
- Dropdown selectors
- Quick action menus
- Option pickers
### Key Principles
1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain
2. **Horizontal ScrollView for options** - Natural for TV remotes (left/right D-pad)
3. **Disable background focus** - Prevent focus flickering between overlay and background
### Implementation
```typescript
import { useState } from "react";
import { View, ScrollView, Pressable, Text } from "react-native";
import { BlurView } from "expo-blur";
const TVOptionSelector: React.FC<{
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
isOpen: boolean;
onClose: () => void;
}> = ({ options, selectedValue, onSelect, isOpen, onClose }) => {
if (!isOpen) return null;
const selectedIndex = options.findIndex(o => o.value === selectedValue);
return (
<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,
paddingVertical: 32,
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVOptionCard
key={option.value}
label={option.label}
isSelected={option.value === selectedValue}
hasTVPreferredFocus={index === selectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</BlurView>
</View>
);
};
```
### Option Card Component
```typescript
import { useState, useRef, useEffect } from "react";
import { Pressable, Text, Animated } from "react-native";
const TVOptionCard: React.FC<{
label: string;
isSelected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, isSelected, hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (toValue: number) => {
Animated.spring(scale, {
toValue,
useNativeDriver: true,
tension: 50,
friction: 7,
}).start();
};
return (
<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)",
paddingHorizontal: 24,
paddingVertical: 16,
borderRadius: 12,
borderWidth: isSelected ? 2 : 0,
borderColor: "#fff",
}}
>
<Text style={{ color: focused ? "#000" : "#fff", fontSize: 18 }}>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
```
### Reference Implementation
See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`.
---
## Focus Management for Overlays
**CRITICAL**: When displaying overlays on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop.
### Solution
Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible:
```typescript
// 1. Track modal state
const [openModal, setOpenModal] = useState<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 a complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
---
## Focus Trapping
For modals that should trap focus (prevent navigation outside the modal), use `TVFocusGuideView` with trap props:
```typescript
import { TVFocusGuideView } from "react-native";
<TVFocusGuideView
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
>
{/* Modal content - focus cannot escape */}
</TVFocusGuideView>
```
**Warning**: Don't use `autoFocus` on focus guide wrappers when you also have bidirectional focus guides - it can interfere with navigation.
---
## Common Mistakes
| Mistake | Result | Fix |
|---------|--------|-----|
| Using React Native `Modal` | Focus chain breaks | Use navigation-based or absolute positioning |
| Overlay without disabling background focus | Focus flickering loop | Add `disabled` prop to all background focusables |
| No `hasTVPreferredFocus` in modal | Focus stuck on background | Set preferred focus on first modal element |
| Missing `presentation: "transparentModal"` | Modal not transparent | Add to Stack.Screen options |
| Not clearing atom on unmount | Stale data on reopen | Clear in useEffect cleanup |
---
## When to Use Which Pattern
| Scenario | Pattern |
|----------|---------|
| Full-screen modal with back button | Navigation-based modal |
| Confirmation dialogs | Navigation-based modal |
| Option selectors / dropdowns | Bottom sheet (inline) |
| Quick action menus | Bottom sheet (inline) |
| Complex forms | Navigation-based modal |