From 2a9f4c2885d929b1baae4bfdbbcc04ef1421cc03 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH] fix: design --- .../jellyseerr/discover/TVDiscoverSlide.tsx | 49 ++- components/jellyseerr/tv/TVJellyseerrPage.tsx | 247 +++++++------- .../search/TVJellyseerrSearchResults.tsx | 45 ++- components/series/TVEpisodeCard.tsx | 4 + components/series/TVSeriesPage.tsx | 100 +++--- components/tv/TVButton.tsx | 6 + components/tv/TVFocusablePoster.tsx | 12 +- docs/tv-focus-guide.md | 317 +++++++++++------- 8 files changed, 457 insertions(+), 323 deletions(-) diff --git a/components/jellyseerr/discover/TVDiscoverSlide.tsx b/components/jellyseerr/discover/TVDiscoverSlide.tsx index 876b96bb..6d750fea 100644 --- a/components/jellyseerr/discover/TVDiscoverSlide.tsx +++ b/components/jellyseerr/discover/TVDiscoverSlide.tsx @@ -15,6 +15,7 @@ import { useJellyseerr, } from "@/hooks/useJellyseerr"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; +import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; import type { MovieResult, @@ -35,7 +36,7 @@ const TVDiscoverPoster: React.FC = ({ const router = useRouter(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08 }); + useTVFocusAnimation({ scaleAmount: 1.05 }); const posterUrl = item.posterPath ? jellyseerrApi?.imageProxy(item.posterPath, "w342") @@ -44,6 +45,10 @@ const TVDiscoverPoster: React.FC = ({ const title = getTitle(item); const year = getYear(item); + const isInLibrary = + item.mediaInfo?.status === MediaStatus.AVAILABLE || + item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; + const handlePress = () => { router.push({ pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", @@ -65,23 +70,21 @@ const TVDiscoverPoster: React.FC = ({ style={[ animatedStyle, { - width: 180, + width: 210, shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, - shadowOpacity: focused ? 0.4 : 0, - shadowRadius: focused ? 12 : 0, + shadowOpacity: focused ? 0.6 : 0, + shadowRadius: focused ? 20 : 0, }, ]} > {posterUrl ? ( @@ -107,13 +110,30 @@ const TVDiscoverPoster: React.FC = ({ /> )} + {isInLibrary && ( + + + + )} @@ -122,10 +142,9 @@ const TVDiscoverPoster: React.FC = ({ {year && ( {year} diff --git a/components/jellyseerr/tv/TVJellyseerrPage.tsx b/components/jellyseerr/tv/TVJellyseerrPage.tsx index f3660972..e81ca4db 100644 --- a/components/jellyseerr/tv/TVJellyseerrPage.tsx +++ b/components/jellyseerr/tv/TVJellyseerrPage.tsx @@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next"; import { Animated, Dimensions, - FlatList, Pressable, ScrollView, TVFocusGuideView, @@ -61,7 +60,6 @@ interface TVCastCardProps { }; imageProxy: (path: string, size?: string) => string; onPress: () => void; - isFirst?: boolean; refSetter?: (ref: View | null) => void; } @@ -69,7 +67,6 @@ const TVCastCard: React.FC = ({ person, imageProxy, onPress, - isFirst, refSetter, }) => { const { focused, handleFocus, handleBlur, animatedStyle } = @@ -85,7 +82,6 @@ const TVCastCard: React.FC = ({ onPress={onPress} onFocus={handleFocus} onBlur={handleBlur} - hasTVPreferredFocus={isFirst} > void; + refSetter?: (ref: View | null) => void; } const TVSeasonCard: React.FC = ({ @@ -182,6 +179,7 @@ const TVSeasonCard: React.FC = ({ canRequest, disabled = false, onCardFocus, + refSetter, }) => { const { t } = useTranslation(); const { focused, handleFocus, handleBlur, animatedStyle } = @@ -194,6 +192,7 @@ const TVSeasonCard: React.FC = ({ return ( = ({ animatedStyle, { minWidth: 180, - padding: 16, + paddingVertical: 18, + paddingHorizontal: 32, backgroundColor: focused ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.08)", @@ -221,42 +221,46 @@ const TVSeasonCard: React.FC = ({ }, ]} > - + + + + {t("jellyseerr.season_number", { + season_number: season.seasonNumber, + })} + + + - {t("jellyseerr.season_number", { - season_number: season.seasonNumber, + {t("jellyseerr.number_episodes", { + episode_number: season.episodeCount, })} - - - {t("jellyseerr.number_episodes", { - episode_number: season.episodeCount, - })} - ); @@ -279,9 +283,10 @@ export const TVJellyseerrPage: React.FC = () => { const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const { showRequestModal } = useTVRequestModal(); - const [lastActionButtonRef, setLastActionButtonRef] = useState( - null, - ); + + // Refs for TVFocusGuideView destinations (useState triggers re-render when set) + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstCastCardRef, setFirstCastCardRef] = useState(null); // Scroll control ref const mainScrollRef = useRef(null); @@ -757,7 +762,7 @@ export const TVJellyseerrPage: React.FC = () => { onPress={handlePlay} hasTVPreferredFocus variant='primary' - refSetter={!canRequest ? setLastActionButtonRef : undefined} + refSetter={setPlayButtonRef} > { )} - {canRequest && ( + {/* Request button - only show for movies, TV series use Request All + season cards */} + {canRequest && mediaType === MediaType.MOVIE && ( { )} + + {/* Request All button for TV series */} + {mediaType === MediaType.TV && + seasons.filter((s) => s.seasonNumber !== 0).length > 0 && + !allSeasonsAvailable && ( + + + + + {t("jellyseerr.request_all")} + + + + )} + + {/* Individual season cards for TV series */} + {mediaType === MediaType.TV && + orderBy( + seasons.filter((s) => s.seasonNumber !== 0), + "seasonNumber", + "desc", + ).map((season) => { + const canRequestSeason = + season.status === MediaStatus.UNKNOWN; + return ( + handleSeasonRequest(season.seasonNumber)} + canRequest={canRequestSeason} + onCardFocus={handleSeasonsFocus} + /> + ); + })} {/* Approve/Decline for managers */} @@ -867,67 +929,6 @@ export const TVJellyseerrPage: React.FC = () => { - {/* Seasons section (TV shows only) */} - {mediaType === MediaType.TV && - seasons.filter((s) => s.seasonNumber !== 0).length > 0 && ( - - - {t("item_card.seasons")} - - - - {!allSeasonsAvailable && ( - - - - {t("jellyseerr.request_all")} - - - )} - {orderBy( - seasons.filter((s) => s.seasonNumber !== 0), - "seasonNumber", - "desc", - ).map((season) => { - const canRequestSeason = - season.status === MediaStatus.UNKNOWN; - return ( - handleSeasonRequest(season.seasonNumber)} - canRequest={canRequestSeason} - onCardFocus={handleSeasonsFocus} - /> - ); - })} - - - )} - {/* Cast section */} {cast.length > 0 && jellyseerrApi && ( @@ -942,35 +943,51 @@ export const TVJellyseerrPage: React.FC = () => { {t("jellyseerr.cast")} - {/* Focus guide for upward navigation from cast to action buttons */} - {lastActionButtonRef && ( + {/* Focus guides for bidirectional navigation - stacked together */} + {/* Downward: action buttons → first cast card */} + {firstCastCardRef && ( + )} + {/* Upward: cast → action buttons */} + {playButtonRef && ( + )} - item.id.toString()} showsHorizontalScrollIndicator={false} + style={{ overflow: "visible" }} contentContainerStyle={{ paddingVertical: 16, gap: 28, }} - style={{ overflow: "visible" }} - renderItem={({ item, index }) => ( + > + {cast.map((person, index) => ( jellyseerrApi.imageProxy(path, size || "w185") } - onPress={() => handleCastPress(item.id)} - isFirst={index === 0} + onPress={() => handleCastPress(person.id)} + refSetter={index === 0 ? setFirstCastCardRef : undefined} /> - )} - /> + ))} + )} diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx index dd0e5507..d696e9d4 100644 --- a/components/search/TVJellyseerrSearchResults.tsx +++ b/components/search/TVJellyseerrSearchResults.tsx @@ -7,6 +7,7 @@ import { Text } from "@/components/common/Text"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { TVTypography } from "@/constants/TVTypography"; import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import type { MovieResult, PersonResult, @@ -28,7 +29,7 @@ const TVJellyseerrPoster: React.FC = ({ }) => { const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08 }); + useTVFocusAnimation({ scaleAmount: 1.05 }); const posterUrl = item.posterPath ? jellyseerrApi?.imageProxy(item.posterPath, "w342") @@ -37,6 +38,10 @@ const TVJellyseerrPoster: React.FC = ({ const title = getTitle(item); const year = getYear(item); + const isInLibrary = + item.mediaInfo?.status === MediaStatus.AVAILABLE || + item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; + return ( = ({ width: 210, shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, - shadowOpacity: focused ? 0.4 : 0, - shadowRadius: focused ? 12 : 0, + shadowOpacity: focused ? 0.6 : 0, + shadowRadius: focused ? 20 : 0, }, ]} > {posterUrl ? ( @@ -90,13 +93,30 @@ const TVJellyseerrPoster: React.FC = ({ /> )} + {isInLibrary && ( + + + + )} @@ -105,10 +125,9 @@ const TVJellyseerrPoster: React.FC = ({ {year && ( {year} diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx index 95bd1c6e..8cfb935d 100644 --- a/components/series/TVEpisodeCard.tsx +++ b/components/series/TVEpisodeCard.tsx @@ -20,6 +20,8 @@ interface TVEpisodeCardProps { onPress: () => void; onFocus?: () => void; onBlur?: () => void; + /** Setter function for the ref (for focus guide destinations) */ + refSetter?: (ref: View | null) => void; } export const TVEpisodeCard: React.FC = ({ @@ -29,6 +31,7 @@ export const TVEpisodeCard: React.FC = ({ onPress, onFocus, onBlur, + refSetter, }) => { const api = useAtomValue(apiAtom); @@ -71,6 +74,7 @@ export const TVEpisodeCard: React.FC = ({ disabled={disabled} onFocus={onFocus} onBlur={onBlur} + refSetter={refSetter} > void; }> = ({ onPress, children, hasTVPreferredFocus, disabled = false, variant = "primary", + refSetter, }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -86,6 +85,7 @@ const TVFocusableButton: React.FC<{ return ( { setFocused(true); @@ -232,17 +232,21 @@ export const TVSeriesPage: React.FC = ({ // Season selector modal state const [isSeasonModalVisible, setIsSeasonModalVisible] = useState(false); + // Focus guide refs (using useState to trigger re-renders when refs are set) + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstEpisodeRef, setFirstEpisodeRef] = useState(null); + // ScrollView ref for page scrolling const mainScrollRef = useRef(null); - // FlatList ref for scrolling back - const episodeListRef = useRef>(null); + // ScrollView ref for scrolling back + const episodeListRef = useRef(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); // Scroll back to start when episode list loses focus useEffect(() => { if (prevFocusedCount.current > 0 && focusedCount === 0) { - episodeListRef.current?.scrollToOffset({ offset: 0, animated: true }); + episodeListRef.current?.scrollTo({ x: 0, animated: true }); // Scroll page back to top when leaving episode section mainScrollRef.current?.scrollTo({ y: 0, animated: true }); } @@ -422,37 +426,6 @@ export const TVSeriesPage: React.FC = ({ })); }, [seasons, selectedSeasonIndex]); - // Episode list item layout - const getItemLayout = useCallback( - (_data: ArrayLike | null | undefined, index: number) => ({ - length: TV_EPISODE_WIDTH + ITEM_GAP, - offset: (TV_EPISODE_WIDTH + ITEM_GAP) * index, - index, - }), - [], - ); - - // Render episode card - const renderEpisode = useCallback( - ({ item: episode }: { item: BaseItemDto; index: number }) => ( - - handleEpisodePress(episode)} - onFocus={handleEpisodeFocus} - onBlur={handleEpisodeBlur} - disabled={isSeasonModalVisible} - /> - - ), - [ - handleEpisodePress, - handleEpisodeFocus, - handleEpisodeBlur, - isSeasonModalVisible, - ], - ); - // Get play button text const playButtonText = useMemo(() => { if (!nextUnwatchedEpisode) return t("common.play"); @@ -574,6 +547,7 @@ export const TVSeriesPage: React.FC = ({ hasTVPreferredFocus={!isSeasonModalVisible} disabled={isSeasonModalVisible} variant='primary' + refSetter={setPlayButtonRef} > = ({ {selectedSeasonName} - + )} + {/* Upward: episodes → Play button */} + {playButtonRef && ( + + )} + + ep.Id!} - renderItem={renderEpisode} showsHorizontalScrollIndicator={false} - initialNumToRender={5} - maxToRenderPerBatch={3} - windowSize={5} - removeClippedSubviews={false} - getItemLayout={getItemLayout} style={{ overflow: "visible" }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, paddingHorizontal: SCALE_PADDING, + gap: ITEM_GAP, }} - ListEmptyComponent={ + > + {episodesForSeason.length > 0 ? ( + episodesForSeason.map((episode, index) => ( + handleEpisodePress(episode)} + onFocus={handleEpisodeFocus} + onBlur={handleEpisodeBlur} + disabled={isSeasonModalVisible} + // Pass refSetter to first episode for focus guide destination + // Note: Do NOT use hasTVPreferredFocus on focus guide destinations + refSetter={index === 0 ? setFirstEpisodeRef : undefined} + /> + )) + ) : ( = ({ > {t("item_card.no_episodes_for_this_season")} - } - /> + )} + diff --git a/components/tv/TVButton.tsx b/components/tv/TVButton.tsx index 791b0aa1..0522ef5f 100644 --- a/components/tv/TVButton.tsx +++ b/components/tv/TVButton.tsx @@ -12,6 +12,8 @@ export interface TVButtonProps { scaleAmount?: number; square?: boolean; refSetter?: (ref: View | null) => void; + nextFocusDown?: number; + nextFocusUp?: number; } const getButtonStyles = ( @@ -59,6 +61,8 @@ export const TVButton: React.FC = ({ scaleAmount = 1.05, square = false, refSetter, + nextFocusDown, + nextFocusUp, }) => { const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount }); @@ -74,6 +78,8 @@ export const TVButton: React.FC = ({ hasTVPreferredFocus={hasTVPreferredFocus && !disabled} disabled={disabled} focusable={!disabled} + nextFocusDown={nextFocusDown} + nextFocusUp={nextFocusUp} > void; onBlur?: () => void; disabled?: boolean; + /** Setter function for the ref (for focus guide destinations) */ + refSetter?: (ref: View | null) => void; } export const TVFocusablePoster: React.FC = ({ @@ -23,6 +31,7 @@ export const TVFocusablePoster: React.FC = ({ onFocus: onFocusProp, onBlur: onBlurProp, disabled = false, + refSetter, }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -39,6 +48,7 @@ export const TVFocusablePoster: React.FC = ({ return ( { setFocused(true); diff --git a/docs/tv-focus-guide.md b/docs/tv-focus-guide.md index 55ce627e..fc49a93e 100644 --- a/docs/tv-focus-guide.md +++ b/docs/tv-focus-guide.md @@ -2,6 +2,54 @@ 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) + + ... + + +// ✅ Works on both tvOS and Android TV + + ... + +``` + +**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 + } +/> + +// ✅ ScrollView provides reliable focus navigation + + {cast.map((person, index) => ( + + ))} + +``` + +**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: @@ -53,159 +101,160 @@ const [targetRef, setTargetRef] = useState(null); ``` -## Complete Example: Bidirectional Navigation +## Bidirectional Navigation (CRITICAL PATTERN) -This example shows how to create focus navigation between a vertical list of buttons and a horizontal ScrollView of cards. +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. -### Step 1: Convert Components to forwardRef +### The Focus Flickering Problem -Any component that needs to be a focus destination must forward its ref: +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 -const TVOptionButton = React.forwardRef< - View, - { - label: string; - onPress: () => void; - } ->(({ label, onPress }, ref) => { - return ( - - {label} - - ); -}); +// ❌ CAUSES FOCUS FLICKERING - destination has hasTVPreferredFocus + + + {items.map((item, index) => ( + + ))} + -const TVActorCard = React.forwardRef< - View, - { - name: string; - onPress: () => void; - } ->(({ name, onPress }, ref) => { - return ( - - {name} - - ); -}); +// ✅ CORRECT - destination does NOT have hasTVPreferredFocus + + + {items.map((item, index) => ( + + ))} + ``` -### Step 2: Track Refs with State +### Complete Bidirectional Example ```typescript const MyScreen: React.FC = () => { - // Track the first actor card (for downward navigation) - const [firstActorRef, setFirstActorRef] = useState(null); + // Track refs for focus navigation + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstCastCardRef, setFirstCastCardRef] = useState(null); - // Track the last option button (for upward navigation) - const [lastButtonRef, setLastButtonRef] = useState(null); + return ( + + {/* Action buttons section */} + + + Play + + - // ... + {/* Cast section */} + + Cast + + {/* BOTH focus guides stacked together, above the list */} + {/* Downward: Play button → first cast card */} + {firstCastCardRef && ( + + )} + {/* Upward: cast → Play button */} + {playButtonRef && ( + + )} + + {/* Use ScrollView, not FlatList, for reliable focus */} + + {cast.map((person, index) => ( + + ))} + + + + ); }; ``` -### Step 3: Place Focus Guides +### Key Rules for Bidirectional Navigation -```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 - - - -``` +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 guide should be placed **between** the source and destination sections: +The focus guides should be placed **together** above the destination section: ``` ┌─────────────────────────┐ -│ Option Buttons │ ← Source (going down) -│ [Quality] [Audio] │ +│ Action Buttons │ ← Source (going down) +│ [Play] [Request] │ Has hasTVPreferredFocus ✓ └─────────────────────────┘ + ↓ ┌─────────────────────────┐ -│ TVFocusGuideView │ ← Invisible guide (height: 1px) -│ destinations=[actor1] │ Catches downward navigation -└─────────────────────────┘ -┌─────────────────────────┐ -│ TVFocusGuideView │ ← Invisible guide (height: 1px) -│ destinations=[lastBtn] │ Catches upward navigation +│ TVFocusGuideView │ ← Downward guide +│ destinations=[card1] │ ├─────────────────────────┤ -│ Actor Cards │ ← Destination (going down) -│ [👤] [👤] [👤] [👤] │ Source (going up) +│ 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 = ({ + person, + onPress, + refSetter, +}) => { + return ( + + {person.name} + + ); +}; + +// Usage + ``` ## Tips and Gotchas @@ -232,13 +281,25 @@ The focus guide should be placed **between** the source and destination sections ``` -5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child: +5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child when entering a region: ```typescript {/* First focusable child will receive focus */} ``` + **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/ItemContent.tv.tsx` for a complete implementation of bidirectional focus navigation between playback options and the cast list. +See `components/jellyseerr/tv/TVJellyseerrPage.tsx` for a complete implementation of bidirectional focus navigation between action buttons and a cast list.