From d2790f4997a6f64f2291773451adcc467319193d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH] fix(tv): seek --- .../video-player/controls/Controls.tv.tsx | 141 +++++++++++------- components/video-player/controls/constants.ts | 4 +- .../controls/hooks/useRemoteControl.ts | 48 +++++- 3 files changed, 138 insertions(+), 55 deletions(-) diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index ee6565da..f5361105 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -129,6 +129,9 @@ export const Controls: FC = ({ type LastModalType = "audio" | "subtitle" | null; const [lastOpenedModal, setLastOpenedModal] = useState(null); + // Track if play button should have focus (when showing controls via up/down D-pad) + const [focusPlayButton, setFocusPlayButton] = useState(false); + // State for progress bar focus and focus guide refs const [isProgressBarFocused, setIsProgressBarFocused] = useState(false); const [playButtonRef, setPlayButtonRef] = useState(null); @@ -308,6 +311,27 @@ export const Controls: FC = ({ }, 2500); }, []); + // Show minimal seek bar without auto-hide (for continuous seeking) + const showMinimalSeekPersistent = useCallback(() => { + setShowMinimalSeekBar(true); + + // Clear existing timeout - don't set a new one + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + minimalSeekBarTimeoutRef.current = null; + } + }, []); + + // Start the minimal seek bar hide timeout + const startMinimalSeekHideTimeout = useCallback(() => { + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + } + minimalSeekBarTimeoutRef.current = setTimeout(() => { + setShowMinimalSeekBar(false); + }, 2500); + }, []); + // Reset minimal seek bar timeout (call on each seek action) const _resetMinimalSeekTimeout = useCallback(() => { if (minimalSeekBarTimeoutRef.current) { @@ -513,39 +537,7 @@ export const Controls: FC = ({ showMinimalSeek, ]); - // 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, - onMinimalSeekLeft: handleMinimalSeekLeft, - onMinimalSeekRight: handleMinimalSeekRight, - 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; - + // Continuous seeking functions (for button long-press and D-pad long-press) const stopContinuousSeeking = useCallback(() => { if (continuousSeekRef.current) { clearInterval(continuousSeekRef.current); @@ -559,7 +551,10 @@ export const Controls: FC = ({ seekBubbleTimeoutRef.current = setTimeout(() => { setShowSeekBubble(false); }, 2000); - }, []); + + // Start minimal seekbar hide timeout (if it's showing) + startMinimalSeekHideTimeout(); + }, [startMinimalSeekHideTimeout]); const startContinuousSeekForward = useCallback(() => { seekAccelerationRef.current = 1; @@ -621,6 +616,65 @@ export const Controls: FC = ({ updateSeekBubbleTime, ]); + // D-pad long press handlers - show minimal seekbar when controls are hidden + const handleDpadLongSeekForward = useCallback(() => { + if (!showControls) { + showMinimalSeekPersistent(); + } + startContinuousSeekForward(); + }, [showControls, showMinimalSeekPersistent, startContinuousSeekForward]); + + const handleDpadLongSeekBackward = useCallback(() => { + if (!showControls) { + showMinimalSeekPersistent(); + } + startContinuousSeekBackward(); + }, [showControls, showMinimalSeekPersistent, startContinuousSeekBackward]); + + // Callback for remote interactions to reset timeout + const handleRemoteInteraction = useCallback(() => { + controlsInteractionRef.current(); + }, []); + + // Callback for up/down D-pad - show controls with play button focused + const handleVerticalDpad = useCallback(() => { + setFocusPlayButton(true); + setShowControls(true); + }, [setShowControls]); + + const { isSliding: isRemoteSliding } = useRemoteControl({ + showControls, + toggleControls, + togglePlay, + onBack: handleBack, + isProgressBarFocused, + onSeekLeft: handleProgressSeekLeft, + onSeekRight: handleProgressSeekRight, + onMinimalSeekLeft: handleMinimalSeekLeft, + onMinimalSeekRight: handleMinimalSeekRight, + onInteraction: handleRemoteInteraction, + onLongSeekLeftStart: handleDpadLongSeekBackward, + onLongSeekRightStart: handleDpadLongSeekForward, + onLongSeekStop: stopContinuousSeeking, + onVerticalDpad: handleVerticalDpad, + }); + + const hideControls = useCallback(() => { + setShowControls(false); + setFocusPlayButton(false); + }, [setShowControls]); + + const { handleControlsInteraction } = useControlsTimeout({ + showControls, + isSliding: isRemoteSliding, + episodeView: false, + onHideControls: hideControls, + timeout: TV_AUTO_HIDE_TIMEOUT, + disabled: false, + }); + + controlsInteractionRef.current = handleControlsInteraction; + const handlePlayPauseButton = useCallback(() => { togglePlay(); controlsInteractionRef.current(); @@ -820,28 +874,13 @@ export const Controls: FC = ({ disabled={false || !previousItem} size={28} /> - - = ({ onFocus={() => setIsProgressBarFocused(true)} onBlur={() => setIsProgressBarFocused(false)} refSetter={setProgressBarRef} - hasTVPreferredFocus={lastOpenedModal === null} + hasTVPreferredFocus={lastOpenedModal === null && !focusPlayButton} /> diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts index 812df333..06f661db 100644 --- a/components/video-player/controls/constants.ts +++ b/components/video-player/controls/constants.ts @@ -5,8 +5,8 @@ export const CONTROLS_CONSTANTS = { TILE_WIDTH: 150, PROGRESS_UNIT_MS: 1000, // 1 second in ms PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks - LONG_PRESS_INITIAL_SEEK: 10, - LONG_PRESS_ACCELERATION: 1.1, + LONG_PRESS_INITIAL_SEEK: 30, + LONG_PRESS_ACCELERATION: 1.2, LONG_PRESS_INTERVAL: 300, SLIDER_DEBOUNCE_MS: 3, } as const; diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 4436843d..c813d141 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -35,6 +35,14 @@ interface UseRemoteControlProps { onMinimalSeekRight?: () => void; /** Callback for any interaction that should reset the controls timeout */ onInteraction?: () => void; + /** Callback when long press seek left starts (eventKeyAction: 0) */ + onLongSeekLeftStart?: () => void; + /** Callback when long press seek right starts (eventKeyAction: 0) */ + onLongSeekRightStart?: () => void; + /** Callback when long press seek ends (eventKeyAction: 1) */ + onLongSeekStop?: () => void; + /** Callback when up/down D-pad pressed (to show controls with play button focused) */ + onVerticalDpad?: () => void; // Legacy props - kept for backwards compatibility with mobile Controls.tsx // These are ignored in the simplified implementation progress?: SharedValue; @@ -67,6 +75,10 @@ export function useRemoteControl({ onMinimalSeekLeft, onMinimalSeekRight, onInteraction, + onLongSeekLeftStart, + onLongSeekRightStart, + onLongSeekStop, + onVerticalDpad, }: UseRemoteControlProps) { // Keep these for backward compatibility with the component const remoteScrubProgress = useSharedValue(null); @@ -96,9 +108,33 @@ export function useRemoteControl({ return; } - // Handle left/right D-pad - check controls hidden state FIRST + // Handle long press D-pad for continuous seeking (works in both modes) + // Must be checked BEFORE the showControls check to work when controls are hidden + if (evt.eventType === "longLeft") { + if (evt.eventKeyAction === 0 && onLongSeekLeftStart) { + // Key pressed - start continuous seeking backward + onLongSeekLeftStart(); + } else if (evt.eventKeyAction === 1 && onLongSeekStop) { + // Key released - stop seeking + onLongSeekStop(); + } + return; + } + + if (evt.eventType === "longRight") { + if (evt.eventKeyAction === 0 && onLongSeekRightStart) { + // Key pressed - start continuous seeking forward + onLongSeekRightStart(); + } else if (evt.eventKeyAction === 1 && onLongSeekStop) { + // Key released - stop seeking + onLongSeekStop(); + } + return; + } + + // Handle D-pad when controls are hidden if (!showControls) { - // Minimal seek mode when controls are hidden + // Minimal seek mode for left/right if (evt.eventType === "left" && onMinimalSeekLeft) { onMinimalSeekLeft(); return; @@ -107,6 +143,14 @@ export function useRemoteControl({ onMinimalSeekRight(); return; } + // Up/down shows controls with play button focused + if ( + (evt.eventType === "up" || evt.eventType === "down") && + onVerticalDpad + ) { + onVerticalDpad(); + return; + } // For other D-pad presses, show full controls toggleControls(); return;