feat(tv): add bidirectional focus navigation between options and cast list

This commit is contained in:
Fredrik Burmester
2026-01-17 09:10:27 +01:00
parent 8f74c3edc7
commit 41d3e61261
5 changed files with 406 additions and 36 deletions

View File

@@ -303,3 +303,39 @@ When you have a page with multiple focusable zones (e.g., a filter bar above a g
5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.
### TV Focus Guide Navigation (Non-Adjacent Sections)
When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use `TVFocusGuideView` with the `destinations` prop:
```typescript
// 1. Track destination with useState (NOT useRef - won't trigger re-renders)
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
// 2. Place invisible focus guide between sections
{firstCardRef && (
<TVFocusGuideView
destinations={[firstCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
// 3. Target component must use forwardRef
const MyCard = React.forwardRef<View, Props>(({ ... }, ref) => (
<Pressable ref={ref} ...>
...
</Pressable>
));
// 4. Pass state setter as callback ref to first item
{items.map((item, index) => (
<MyCard
ref={index === 0 ? setFirstCardRef : undefined}
...
/>
))}
```
**For detailed documentation and bidirectional navigation patterns, see [docs/tv-focus-guide.md](docs/tv-focus-guide.md)**
**Reference implementation**: See `components/ItemContent.tv.tsx` for bidirectional focus navigation between playback options and cast list.

View File

@@ -540,16 +540,19 @@ const TVOptionCard = React.forwardRef<
});
// Circular actor card with Apple TV style focus animations
const TVActorCard: React.FC<{
person: {
Id?: string | null;
Name?: string | null;
Role?: string | null;
};
apiBasePath?: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}> = ({ person, apiBasePath, onPress, hasTVPreferredFocus }) => {
const TVActorCard = React.forwardRef<
View,
{
person: {
Id?: string | null;
Name?: string | null;
Role?: string | null;
};
apiBasePath?: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}
>(({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -567,6 +570,7 @@ const TVActorCard: React.FC<{
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
@@ -653,7 +657,7 @@ const TVActorCard: React.FC<{
</Animated.View>
</Pressable>
);
};
});
// Series/Season poster card with Apple TV style focus animations
const TVSeriesSeasonCard: React.FC<{
@@ -764,12 +768,15 @@ const TVSeriesSeasonCard: React.FC<{
};
// Button to open option selector
const TVOptionButton: React.FC<{
label: string;
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}> = ({ label, value, onPress, hasTVPreferredFocus }) => {
const TVOptionButton = React.forwardRef<
View,
{
label: string;
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}
>(({ label, value, onPress, hasTVPreferredFocus }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -783,6 +790,7 @@ const TVOptionButton: React.FC<{
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
@@ -836,7 +844,7 @@ const TVOptionButton: React.FC<{
</Animated.View>
</Pressable>
);
};
});
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
@@ -904,6 +912,17 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const [openModal, setOpenModal] = useState<ModalType>(null);
const isModalOpen = openModal !== null;
// State for first actor card ref (used for focus guide)
// Using state instead of useRef to trigger re-renders when ref is set
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
null,
);
// State for last option button ref (used for upward focus guide from cast)
const [lastOptionButtonRef, setLastOptionButtonRef] = useState<View | null>(
null,
);
// Android TV BackHandler for closing modals
useEffect(() => {
if (Platform.OS === "android" && isModalOpen) {
@@ -1101,6 +1120,25 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
// Determine which option button is the last one (for focus guide targeting)
const lastOptionButton = useMemo(() => {
const hasSubtitleOption =
subtitleTracks.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";
}, [
subtitleTracks.length,
selectedOptions?.subtitleIndex,
audioTracks.length,
mediaSources.length,
]);
if (!item || !selectedOptions) return null;
return (
@@ -1350,6 +1388,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
>
{/* Quality selector */}
<TVOptionButton
ref={
lastOptionButton === "quality"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.quality")}
value={selectedQualityLabel}
onPress={() => setOpenModal("quality")}
@@ -1358,6 +1401,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{/* Media source selector (only if multiple sources) */}
{mediaSources.length > 1 && (
<TVOptionButton
ref={
lastOptionButton === "mediaSource"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.video")}
value={selectedMediaSourceLabel}
onPress={() => setOpenModal("mediaSource")}
@@ -1367,6 +1415,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{/* Audio selector */}
{audioTracks.length > 0 && (
<TVOptionButton
ref={
lastOptionButton === "audio"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.audio")}
value={selectedAudioLabel}
onPress={() => setOpenModal("audio")}
@@ -1377,6 +1430,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{(subtitleTracks.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton
ref={
lastOptionButton === "subtitle"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel}
onPress={() => setOpenModal("subtitle")}
@@ -1384,6 +1442,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)}
</View>
{/* Focus guide to direct navigation from options to cast list */}
{fullCast.length > 0 && firstActorCardRef && (
<TVFocusGuideView
destinations={[firstActorCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Progress bar (if partially watched) */}
{hasProgress && item.RunTimeTicks != null && (
<View style={{ maxWidth: 400, marginBottom: 24 }}>
@@ -1443,24 +1509,32 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text>
</View>
)}
{cast && cast.length > 0 && (
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 14,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
}}
>
{t("item_card.cast")}
</Text>
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
{cast.map((c) => c.Name).join(", ")}
</Text>
</View>
)}
{/* Only show text cast if visual cast section won't be shown */}
{cast &&
cast.length > 0 &&
!(
(item.Type === "Movie" ||
item.Type === "Series" ||
item.Type === "Episode") &&
fullCast.length > 0
) && (
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 14,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
}}
>
{t("item_card.cast")}
</Text>
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
{cast.map((c) => c.Name).join(", ")}
</Text>
</View>
)}
</View>
</View>
)}
@@ -1554,6 +1628,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
>
{t("item_card.cast")}
</Text>
{/* Focus guide to direct upward navigation from cast back to options */}
{lastOptionButtonRef && (
<TVFocusGuideView
destinations={[lastOptionButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
@@ -1567,6 +1648,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{fullCast.map((person, index) => (
<TVActorCard
key={person.Id || index}
ref={index === 0 ? setFirstActorCardRef : undefined}
person={person}
apiBasePath={api?.basePath}
onPress={() => {

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

@@ -0,0 +1,244 @@
# 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.
## 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] : []} />
```
## Complete Example: Bidirectional Navigation
This example shows how to create focus navigation between a vertical list of buttons and a horizontal ScrollView of cards.
### Step 1: Convert Components to forwardRef
Any component that needs to be a focus destination must forward its ref:
```typescript
const TVOptionButton = React.forwardRef<
View,
{
label: string;
onPress: () => void;
}
>(({ label, onPress }, ref) => {
return (
<Pressable ref={ref} onPress={onPress}>
<Text>{label}</Text>
</Pressable>
);
});
const TVActorCard = React.forwardRef<
View,
{
name: string;
onPress: () => void;
}
>(({ name, onPress }, ref) => {
return (
<Pressable ref={ref} onPress={onPress}>
<Text>{name}</Text>
</Pressable>
);
});
```
### Step 2: Track Refs with State
```typescript
const MyScreen: React.FC = () => {
// Track the first actor card (for downward navigation)
const [firstActorRef, setFirstActorRef] = useState<View | null>(null);
// Track the last option button (for upward navigation)
const [lastButtonRef, setLastButtonRef] = useState<View | null>(null);
// ...
};
```
### Step 3: Place Focus Guides
```typescript
return (
<View style={{ flex: 1 }}>
{/* Option buttons */}
<View>
<TVOptionButton label="Quality" onPress={...} />
<TVOptionButton label="Audio" onPress={...} />
<TVOptionButton
ref={setLastButtonRef} // Last button gets the ref
label="Subtitles"
onPress={...}
/>
</View>
{/* Focus guide: options → cast (downward navigation) */}
{firstActorRef && (
<TVFocusGuideView
destinations={[firstActorRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Cast section */}
<View>
<Text>Cast</Text>
{/* Focus guide: cast → options (upward navigation) */}
{lastButtonRef && (
<TVFocusGuideView
destinations={[lastButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
<ScrollView horizontal>
{actors.map((actor, index) => (
<TVActorCard
key={actor.id}
ref={index === 0 ? setFirstActorRef : undefined} // First card gets the ref
name={actor.name}
onPress={...}
/>
))}
</ScrollView>
</View>
</View>
);
```
### Step 4: Handle Dynamic "Last" Element
When the last button varies based on conditions (e.g., subtitle button only shows if subtitles exist), compute which one is last:
```typescript
// Determine which button is last
const lastOptionButton = useMemo(() => {
if (hasSubtitles) return "subtitle";
if (hasAudio) return "audio";
return "quality";
}, [hasSubtitles, hasAudio]);
// Pass ref only to the last one
<TVOptionButton
ref={lastOptionButton === "quality" ? setLastButtonRef : undefined}
label="Quality"
onPress={...}
/>
<TVOptionButton
ref={lastOptionButton === "audio" ? setLastButtonRef : undefined}
label="Audio"
onPress={...}
/>
<TVOptionButton
ref={lastOptionButton === "subtitle" ? setLastButtonRef : undefined}
label="Subtitles"
onPress={...}
/>
```
## Focus Guide Placement
The focus guide should be placed **between** the source and destination sections:
```
┌─────────────────────────┐
│ Option Buttons │ ← Source (going down)
│ [Quality] [Audio] │
└─────────────────────────┘
┌─────────────────────────┐
│ TVFocusGuideView │ ← Invisible guide (height: 1px)
│ destinations=[actor1] │ Catches downward navigation
└─────────────────────────┘
┌─────────────────────────┐
│ TVFocusGuideView │ ← Invisible guide (height: 1px)
│ destinations=[lastBtn] │ Catches upward navigation
├─────────────────────────┤
│ Actor Cards │ ← Destination (going down)
│ [👤] [👤] [👤] [👤] │ Source (going up)
└─────────────────────────┘
```
## 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:
```typescript
<TVFocusGuideView autoFocus>
{/* First focusable child will receive focus */}
</TVFocusGuideView>
```
## Reference Implementation
See `components/ItemContent.tv.tsx` for a complete implementation of bidirectional focus navigation between playback options and the cast list.

View File

@@ -88,6 +88,7 @@
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
"continue": "Continue",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
@@ -626,6 +627,9 @@
"series": "Series",
"seasons": "Seasons",
"season": "Season",
"from_this_series": "From This Series",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",

View File

@@ -86,6 +86,7 @@
"oops": "Hoppsan!",
"error_message": "Något gick fel.\nLogga ut och in igen.",
"continue_watching": "Fortsätt titta",
"continue": "Fortsätt",
"next_up": "Näst på tur",
"continue_and_next_up": "Fortsätt titta & nästa avsnitt",
"recently_added_in": "Nyligen tillagt i {{libraryName}}",
@@ -621,6 +622,9 @@
"series": "Serier",
"seasons": "Säsonger",
"season": "Säsong ",
"from_this_series": "Från den här serien",
"view_series": "Visa serien",
"view_season": "Visa säsongen",
"no_episodes_for_this_season": "Inga avsnitt för den här säsongen",
"overview": "Översikt",
"more_with": "Mer med {{name}}",