diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx index 72440f97..654cdfc6 100644 --- a/components/tv/TVNextEpisodeCountdown.tsx +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -7,7 +7,9 @@ import { Image, Pressable, Animated as RNAnimated, + type View as RNView, StyleSheet, + TVFocusGuideView, View, } from "react-native"; import Animated, { @@ -33,6 +35,12 @@ export interface TVNextEpisodeCountdownProps { onPlayNext?: () => void; /** Whether controls are visible - affects card position */ controlsVisible?: boolean; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: RNView | null) => void; + /** Whether this component should receive initial focus */ + hasTVPreferredFocus?: boolean; + /** Destination used when moving down from this card */ + playButtonRef?: RNView | null; } // Position constants @@ -47,6 +55,9 @@ export const TVNextEpisodeCountdown: FC = ({ onFinish, onPlayNext, controlsVisible = false, + refSetter, + hasTVPreferredFocus = true, + playButtonRef: downDestination, }) => { const typography = useScaledTVTypography(); const { t } = useTranslation(); @@ -135,10 +146,11 @@ export const TVNextEpisodeCountdown: FC = ({ pointerEvents='box-none' > @@ -172,6 +184,12 @@ export const TVNextEpisodeCountdown: FC = ({ + {downDestination && ( + + )} ); }; @@ -235,4 +253,8 @@ const createStyles = (typography: ReturnType) => backgroundColor: "#fff", borderRadius: 2, }, + returnFocusGuide: { + height: 1, + width: "100%", + }, }); diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx index 3e53d0f0..bbea61b0 100644 --- a/components/tv/TVSkipSegmentCard.tsx +++ b/components/tv/TVSkipSegmentCard.tsx @@ -2,7 +2,13 @@ import { Ionicons } from "@expo/vector-icons"; import type { FC } from "react"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native"; +import { + Pressable, + Animated as RNAnimated, + StyleSheet, + TVFocusGuideView, + type View, +} from "react-native"; import Animated, { Easing, useAnimatedStyle, @@ -18,6 +24,12 @@ export interface TVSkipSegmentCardProps { type: "intro" | "credits"; /** Whether controls are visible - affects card position */ controlsVisible?: boolean; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: View | null) => void; + /** Whether this component should receive initial focus */ + hasTVPreferredFocus?: boolean; + /** Destination used when moving down from this card */ + playButtonRef?: View | null; } // Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive) @@ -29,6 +41,9 @@ export const TVSkipSegmentCard: FC = ({ onPress, type, controlsVisible = false, + refSetter, + hasTVPreferredFocus = true, + playButtonRef: downDestination, }) => { const { t } = useTranslation(); const { focused, handleFocus, handleBlur, animatedStyle } = @@ -67,10 +82,11 @@ export const TVSkipSegmentCard: FC = ({ pointerEvents='box-none' > = ({ {labelText} + {downDestination && ( + + )} ); }; @@ -114,4 +136,8 @@ const styles = StyleSheet.create({ color: "#fff", fontWeight: "600", }, + returnFocusGuide: { + height: 1, + width: "100%", + }, }); diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index b0e1a886..f980a0eb 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -268,6 +268,8 @@ export const Controls: FC = ({ const [isProgressBarFocused, setIsProgressBarFocused] = useState(false); const [playButtonRef, setPlayButtonRef] = useState(null); const [progressBarRef, setProgressBarRef] = useState(null); + const [skipSegmentRef, setSkipSegmentRef] = useState(null); + const [nextEpisodeRef, setNextEpisodeRef] = useState(null); // Minimal seek bar state (shows only progress bar when seeking while controls hidden) const [showMinimalSeekBar, setShowMinimalSeekBar] = useState(false); @@ -1014,6 +1016,8 @@ export const Controls: FC = ({ goToNextItem({ isAutoPlay: true }); }, [goToNextItem]); + const topOverlayFocusTarget = skipSegmentRef ?? nextEpisodeRef; + return ( = ({ onPress={skipIntro} type='intro' controlsVisible={showControls} + refSetter={setSkipSegmentRef} + hasTVPreferredFocus={!showControls} + playButtonRef={showControls ? playButtonRef : null} /> {/* Skip credits card - show when there's content after credits, OR no next episode */} @@ -1052,6 +1059,9 @@ export const Controls: FC = ({ onPress={skipCredit} type='credits' controlsVisible={showControls} + refSetter={setSkipSegmentRef} + hasTVPreferredFocus={!showControls} + playButtonRef={showControls ? playButtonRef : null} /> {nextItem && ( @@ -1063,6 +1073,9 @@ export const Controls: FC = ({ onFinish={handleAutoPlayFinish} onPlayNext={handleNextItemButton} controlsVisible={showControls} + refSetter={setNextEpisodeRef} + hasTVPreferredFocus={!showControls} + playButtonRef={showControls ? playButtonRef : null} /> )} @@ -1210,6 +1223,14 @@ export const Controls: FC = ({ )} + {/* Upward: control buttons → visible skip segment or next episode card */} + {topOverlayFocusTarget && ( + + )} +