fix: tv overlay focus navigation (#1558)

This commit is contained in:
Steve Byatt
2026-05-20 11:53:01 +01:00
committed by GitHub
parent ca4f24ded0
commit a1c98f9285
3 changed files with 72 additions and 3 deletions

View File

@@ -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<TVNextEpisodeCountdownProps> = ({
onFinish,
onPlayNext,
controlsVisible = false,
refSetter,
hasTVPreferredFocus = true,
playButtonRef: downDestination,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
@@ -135,10 +146,11 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
pointerEvents='box-none'
>
<Pressable
ref={refSetter}
onPress={onPlayNext}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={true}
hasTVPreferredFocus={hasTVPreferredFocus}
focusable={true}
>
<RNAnimated.View style={[animatedStyle, focused && styles.focusedCard]}>
@@ -172,6 +184,12 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
</BlurView>
</RNAnimated.View>
</Pressable>
{downDestination && (
<TVFocusGuideView
destinations={[downDestination]}
style={styles.returnFocusGuide}
/>
)}
</Animated.View>
);
};
@@ -235,4 +253,8 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
backgroundColor: "#fff",
borderRadius: 2,
},
returnFocusGuide: {
height: 1,
width: "100%",
},
});

View File

@@ -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<TVSkipSegmentCardProps> = ({
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<TVSkipSegmentCardProps> = ({
pointerEvents='box-none'
>
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={true}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<RNAnimated.View
style={[
@@ -90,6 +106,12 @@ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
<Text style={styles.label}>{labelText}</Text>
</RNAnimated.View>
</Pressable>
{downDestination && (
<TVFocusGuideView
destinations={[downDestination]}
style={styles.returnFocusGuide}
/>
)}
</Animated.View>
);
};
@@ -114,4 +136,8 @@ const styles = StyleSheet.create({
color: "#fff",
fontWeight: "600",
},
returnFocusGuide: {
height: 1,
width: "100%",
},
});

View File

@@ -268,6 +268,8 @@ export const Controls: FC<Props> = ({
const [isProgressBarFocused, setIsProgressBarFocused] = useState(false);
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [progressBarRef, setProgressBarRef] = useState<View | null>(null);
const [skipSegmentRef, setSkipSegmentRef] = useState<View | null>(null);
const [nextEpisodeRef, setNextEpisodeRef] = useState<View | null>(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<Props> = ({
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
const topOverlayFocusTarget = skipSegmentRef ?? nextEpisodeRef;
return (
<View style={styles.controlsContainer} pointerEvents='box-none'>
<Animated.View
@@ -1040,6 +1044,9 @@ export const Controls: FC<Props> = ({
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<Props> = ({
onPress={skipCredit}
type='credits'
controlsVisible={showControls}
refSetter={setSkipSegmentRef}
hasTVPreferredFocus={!showControls}
playButtonRef={showControls ? playButtonRef : null}
/>
{nextItem && (
@@ -1063,6 +1073,9 @@ export const Controls: FC<Props> = ({
onFinish={handleAutoPlayFinish}
onPlayNext={handleNextItemButton}
controlsVisible={showControls}
refSetter={setNextEpisodeRef}
hasTVPreferredFocus={!showControls}
playButtonRef={showControls ? playButtonRef : null}
/>
)}
@@ -1210,6 +1223,14 @@ export const Controls: FC<Props> = ({
)}
</View>
{/* Upward: control buttons → visible skip segment or next episode card */}
{topOverlayFocusTarget && (
<TVFocusGuideView
destinations={[topOverlayFocusTarget]}
style={styles.focusGuide}
/>
)}
<View style={styles.controlButtonsRow}>
<TVControlButton
icon='play-skip-back'