mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 20:18:29 +01:00
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:
436
docs/research/hdr-mpv.md
Normal file
436
docs/research/hdr-mpv.md
Normal 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
136
docs/tv-discovery.md
Normal 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
305
docs/tv-focus-guide.md
Normal 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
416
docs/tv-modal-guide.md
Normal 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 |
|
||||
Reference in New Issue
Block a user