From d8512897ad0d7162421488dd452e16de8356dc02 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH] feat: seekbar left/right actions --- components/tv/TVControlButton.tsx | 11 +- components/tv/TVFocusableProgressBar.tsx | 137 ++++++++++++++++ .../video-player/controls/Controls.tv.tsx | 154 +++++++++++++----- .../controls/hooks/useRemoteControl.ts | 30 +++- 4 files changed, 286 insertions(+), 46 deletions(-) create mode 100644 components/tv/TVFocusableProgressBar.tsx diff --git a/components/tv/TVControlButton.tsx b/components/tv/TVControlButton.tsx index 72e06ca2..49604ba5 100644 --- a/components/tv/TVControlButton.tsx +++ b/components/tv/TVControlButton.tsx @@ -1,6 +1,11 @@ import { Ionicons } from "@expo/vector-icons"; import type { FC } from "react"; -import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native"; +import { + Pressable, + Animated as RNAnimated, + StyleSheet, + type View, +} from "react-native"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVControlButtonProps { @@ -12,6 +17,8 @@ export interface TVControlButtonProps { hasTVPreferredFocus?: boolean; size?: number; delayLongPress?: number; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: View | null) => void; } export const TVControlButton: FC = ({ @@ -23,12 +30,14 @@ export const TVControlButton: FC = ({ hasTVPreferredFocus, size = 32, delayLongPress = 300, + refSetter, }) => { const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 }); return ( ; + /** Maximum value in milliseconds */ + max: SharedValue; + /** Cache progress value (SharedValue) in milliseconds */ + cacheProgress?: SharedValue; + /** Callback when the progress bar receives focus */ + onFocus?: () => void; + /** Callback when the progress bar loses focus */ + onBlur?: () => void; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: View | null) => void; + /** Whether this component is disabled */ + disabled?: boolean; + /** Whether this component should receive initial focus */ + hasTVPreferredFocus?: boolean; + /** Optional style overrides */ + style?: ViewStyle; +} + +const PROGRESS_BAR_HEIGHT = 16; + +export const TVFocusableProgressBar: React.FC = + React.memo( + ({ + progress, + max, + cacheProgress, + onFocus, + onBlur, + refSetter, + disabled = false, + hasTVPreferredFocus = false, + style, + }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.02, + duration: 120, + onFocus, + onBlur, + }); + + const progressFillStyle = useAnimatedStyle(() => ({ + width: `${max.value > 0 ? (progress.value / max.value) * 100 : 0}%`, + })); + + const cacheProgressStyle = useAnimatedStyle(() => ({ + width: `${max.value > 0 && cacheProgress ? (cacheProgress.value / max.value) * 100 : 0}%`, + })); + + return ( + + + + {cacheProgress && ( + + )} + + + + + ); + }, + ); + +const styles = StyleSheet.create({ + pressableContainer: { + // Add padding for focus scale animation to not clip + paddingVertical: 4, + paddingHorizontal: 4, + }, + animatedContainer: { + height: PROGRESS_BAR_HEIGHT + 8, + justifyContent: "center", + borderRadius: 12, + paddingHorizontal: 4, + }, + progressTrack: { + height: PROGRESS_BAR_HEIGHT, + backgroundColor: "rgba(255,255,255,0.2)", + borderRadius: 8, + overflow: "hidden", + }, + cacheProgress: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "rgba(255,255,255,0.3)", + borderRadius: 8, + }, + progressFill: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "#fff", + borderRadius: 8, + }, +}); diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 21e4db31..afdb9bdb 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -13,7 +13,7 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { StyleSheet, View } from "react-native"; +import { StyleSheet, TVFocusGuideView, View } from "react-native"; import Animated, { Easing, type SharedValue, @@ -25,6 +25,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv"; +import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar"; import useRouter from "@/hooks/useAppRouter"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; @@ -128,6 +129,11 @@ export const Controls: FC = ({ type LastModalType = "audio" | "subtitle" | null; const [lastOpenedModal, setLastOpenedModal] = useState(null); + // State for progress bar focus and focus guide refs + const [isProgressBarFocused, setIsProgressBarFocused] = useState(false); + const [playButtonRef, setPlayButtonRef] = useState(null); + const [progressBarRef, setProgressBarRef] = useState(null); + const audioTracks = useMemo(() => { return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; }, [mediaSource]); @@ -249,13 +255,6 @@ export const Controls: FC = ({ // No longer needed since modals are screen-based }, []); - const { isSliding: isRemoteSliding } = useRemoteControl({ - showControls, - toggleControls, - togglePlay, - onBack: handleBack, - }); - const handleOpenAudioSheet = useCallback(() => { setLastOpenedModal("audio"); showOptions({ @@ -319,21 +318,6 @@ export const Controls: FC = ({ [], ); - const hideControls = useCallback(() => { - setShowControls(false); - }, [setShowControls]); - - const { handleControlsInteraction } = useControlsTimeout({ - showControls, - isSliding: isRemoteSliding, - episodeView: false, - onHideControls: hideControls, - timeout: TV_AUTO_HIDE_TIMEOUT, - disabled: false, - }); - - controlsInteractionRef.current = handleControlsInteraction; - const handleSeekForwardButton = useCallback(() => { const newPosition = Math.min(max.value, progress.value + 30 * 1000); progress.value = newPosition; @@ -372,6 +356,76 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + // Progress bar D-pad seeking (10s increments for finer control) + const handleProgressSeekRight = useCallback(() => { + const newPosition = Math.min(max.value, progress.value + 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + + const handleProgressSeekLeft = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + + // Callback for remote interactions to reset timeout + const handleRemoteInteraction = useCallback(() => { + controlsInteractionRef.current(); + }, []); + + const { isSliding: isRemoteSliding } = useRemoteControl({ + showControls, + toggleControls, + togglePlay, + onBack: handleBack, + isProgressBarFocused, + onSeekLeft: handleProgressSeekLeft, + onSeekRight: handleProgressSeekRight, + onInteraction: handleRemoteInteraction, + }); + + const hideControls = useCallback(() => { + setShowControls(false); + }, [setShowControls]); + + const { handleControlsInteraction } = useControlsTimeout({ + showControls, + isSliding: isRemoteSliding, + episodeView: false, + onHideControls: hideControls, + timeout: TV_AUTO_HIDE_TIMEOUT, + disabled: false, + }); + + controlsInteractionRef.current = handleControlsInteraction; + const stopContinuousSeeking = useCallback(() => { if (continuousSeekRef.current) { clearInterval(continuousSeekRef.current); @@ -590,8 +644,8 @@ export const Controls: FC = ({ icon={isPlaying ? "pause" : "play"} onPress={handlePlayPauseButton} disabled={false} - hasTVPreferredFocus={!false && lastOpenedModal === null} size={36} + refSetter={setPlayButtonRef} /> = ({ )} - - - ({ - width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> - ({ - width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> - - + {/* Bidirectional focus guides - stacked together per docs */} + {/* Downward: play button → progress bar */} + {progressBarRef && ( + + )} + {/* Upward: progress bar → play button */} + {playButtonRef && ( + + )} + + {/* Progress bar with focus trapping for left/right */} + + setIsProgressBarFocused(true)} + onBlur={() => setIsProgressBarFocused(false)} + refSetter={setProgressBarRef} + hasTVPreferredFocus={lastOpenedModal === null} + /> + @@ -735,6 +797,10 @@ const styles = StyleSheet.create({ alignItems: "center", zIndex: 20, }, + focusGuide: { + height: 1, + width: "100%", + }, progressBarContainer: { height: TV_SEEKBAR_HEIGHT, justifyContent: "center", diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 406a99c3..664d30f3 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -23,6 +23,14 @@ interface UseRemoteControlProps { disableSeeking?: boolean; /** Callback for back/menu button press (tvOS: menu, Android TV: back) */ onBack?: () => void; + /** Whether the progress bar currently has focus */ + isProgressBarFocused?: boolean; + /** Callback for seeking left when progress bar is focused */ + onSeekLeft?: () => void; + /** Callback for seeking right when progress bar is focused */ + onSeekRight?: () => void; + /** Callback for any interaction that should reset the controls timeout */ + onInteraction?: () => void; // Legacy props - kept for backwards compatibility with mobile Controls.tsx // These are ignored in the simplified implementation progress?: SharedValue; @@ -49,6 +57,10 @@ export function useRemoteControl({ toggleControls, togglePlay, onBack, + isProgressBarFocused, + onSeekLeft, + onSeekRight, + onInteraction, }: UseRemoteControlProps) { // Keep these for backward compatibility with the component const remoteScrubProgress = useSharedValue(null); @@ -74,12 +86,28 @@ export function useRemoteControl({ if (togglePlay) { togglePlay(); } + onInteraction?.(); return; } - // Show controls on any D-pad press + // Handle left/right D-pad seeking when progress bar is focused + if (isProgressBarFocused) { + if (evt.eventType === "left" && onSeekLeft) { + onSeekLeft(); + return; + } + if (evt.eventType === "right" && onSeekRight) { + onSeekRight(); + return; + } + } + + // Show controls on any D-pad press, or reset timeout if already showing if (!showControls) { toggleControls(); + } else { + // Reset the timeout on any D-pad navigation when controls are showing + onInteraction?.(); } });