diff --git a/CLAUDE.md b/CLAUDE.md index 5007cbc9..858d15ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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(null); + +// 2. Place invisible focus guide between sections +{firstCardRef && ( + +)} + +// 3. Target component must use forwardRef +const MyCard = React.forwardRef(({ ... }, ref) => ( + + ... + +)); + +// 4. Pass state setter as callback ref to first item +{items.map((item, index) => ( + +))} +``` + +**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. diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index e3083a49..d3591ad5 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -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 ( { setFocused(true); @@ -653,7 +657,7 @@ const TVActorCard: React.FC<{ ); -}; +}); // 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 ( { setFocused(true); @@ -836,7 +844,7 @@ const TVOptionButton: React.FC<{ ); -}; +}); // Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) export const ItemContentTV: React.FC = React.memo( @@ -904,6 +912,17 @@ export const ItemContentTV: React.FC = React.memo( const [openModal, setOpenModal] = useState(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( + null, + ); + + // State for last option button ref (used for upward focus guide from cast) + const [lastOptionButtonRef, setLastOptionButtonRef] = useState( + null, + ); + // Android TV BackHandler for closing modals useEffect(() => { if (Platform.OS === "android" && isModalOpen) { @@ -1101,6 +1120,25 @@ export const ItemContentTV: React.FC = 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 = React.memo( > {/* Quality selector */} setOpenModal("quality")} @@ -1358,6 +1401,11 @@ export const ItemContentTV: React.FC = React.memo( {/* Media source selector (only if multiple sources) */} {mediaSources.length > 1 && ( setOpenModal("mediaSource")} @@ -1367,6 +1415,11 @@ export const ItemContentTV: React.FC = React.memo( {/* Audio selector */} {audioTracks.length > 0 && ( setOpenModal("audio")} @@ -1377,6 +1430,11 @@ export const ItemContentTV: React.FC = React.memo( {(subtitleTracks.length > 0 || selectedOptions?.subtitleIndex !== undefined) && ( setOpenModal("subtitle")} @@ -1384,6 +1442,14 @@ export const ItemContentTV: React.FC = React.memo( )} + {/* Focus guide to direct navigation from options to cast list */} + {fullCast.length > 0 && firstActorCardRef && ( + + )} + {/* Progress bar (if partially watched) */} {hasProgress && item.RunTimeTicks != null && ( @@ -1443,24 +1509,32 @@ export const ItemContentTV: React.FC = React.memo( )} - {cast && cast.length > 0 && ( - - - {t("item_card.cast")} - - - {cast.map((c) => c.Name).join(", ")} - - - )} + {/* 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 + ) && ( + + + {t("item_card.cast")} + + + {cast.map((c) => c.Name).join(", ")} + + + )} )} @@ -1554,6 +1628,13 @@ export const ItemContentTV: React.FC = React.memo( > {t("item_card.cast")} + {/* Focus guide to direct upward navigation from cast back to options */} + {lastOptionButtonRef && ( + + )} = React.memo( {fullCast.map((person, index) => ( { diff --git a/docs/tv-focus-guide.md b/docs/tv-focus-guide.md new file mode 100644 index 00000000..55ce627e --- /dev/null +++ b/docs/tv-focus-guide.md @@ -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(null); + +// 2. Place an invisible focus guide between sections +{targetRef && ( + +)} + +// 3. Pass the state setter as a callback ref to the target + +``` + +### 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(null); + + +// ✅ Works - useState triggers re-render when ref is set +const [targetRef, setTargetRef] = useState(null); + +``` + +## 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 ( + + {label} + + ); +}); + +const TVActorCard = React.forwardRef< + View, + { + name: string; + onPress: () => void; + } +>(({ name, onPress }, ref) => { + return ( + + {name} + + ); +}); +``` + +### Step 2: Track Refs with State + +```typescript +const MyScreen: React.FC = () => { + // Track the first actor card (for downward navigation) + const [firstActorRef, setFirstActorRef] = useState(null); + + // Track the last option button (for upward navigation) + const [lastButtonRef, setLastButtonRef] = useState(null); + + // ... +}; +``` + +### Step 3: Place Focus Guides + +```typescript +return ( + + {/* Option buttons */} + + + + + + + {/* Focus guide: options → cast (downward navigation) */} + {firstActorRef && ( + + )} + + {/* Cast section */} + + Cast + + {/* Focus guide: cast → options (upward navigation) */} + {lastButtonRef && ( + + )} + + + {actors.map((actor, index) => ( + + ))} + + + +); +``` + +### 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 + + + +``` + +## 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 && } + ``` + +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 + + ``` + +4. **Focus trapping**: Use `trapFocusUp`, `trapFocusDown`, etc. to prevent focus from leaving a region (useful for modals): + ```typescript + + {/* Modal content */} + + ``` + +5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child: + ```typescript + + {/* First focusable child will receive focus */} + + ``` + +## Reference Implementation + +See `components/ItemContent.tv.tsx` for a complete implementation of bidirectional focus navigation between playback options and the cast list. diff --git a/translations/en.json b/translations/en.json index f9df7d5a..7bbd046b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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", diff --git a/translations/sv.json b/translations/sv.json index e5c370db..214675e1 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -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}}",