mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-20 13:56:37 +01:00
fix: tv overlay focus navigation (#1558)
This commit is contained in:
@@ -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%",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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%",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user