diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx index 6582cacb8..47193b0e1 100644 --- a/components/tv/TVNextEpisodeCountdown.tsx +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -63,6 +63,7 @@ export const TVNextEpisodeCountdown: FC = ({ const typography = useScaledTVTypography(); const { t } = useTranslation(); const progress = useSharedValue(0); + const cancelled = useSharedValue(false); const onFinishRef = useRef(onFinish); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ @@ -120,13 +121,15 @@ export const TVNextEpisodeCountdown: FC = ({ return; } + cancelled.value = false; + // Resume from current position const remainingDuration = (1 - progress.value) * 8000; progress.value = withTiming( 1, { duration: remainingDuration, easing: Easing.linear }, (finished) => { - if (finished) { + if (finished && !cancelled.value) { runOnJS(onFinishRef.current)(); } }, @@ -134,9 +137,10 @@ export const TVNextEpisodeCountdown: FC = ({ // Cancel animation on unmount to prevent onFinish from firing after exit return () => { + cancelled.value = true; cancelAnimation(progress); }; - }, [show, isPlaying, progress]); + }, [show, isPlaying, progress, cancelled]); const progressStyle = useAnimatedStyle(() => ({ width: `${progress.value * 100}%`, diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 657f2a8cd..2faecbaaf 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -517,6 +517,8 @@ export const Controls: FC = ({ const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>( () => {}, ); + const exitingRef = useRef(false); + const [isExiting, setIsExiting] = useState(false); const updateSeekBubbleTime = useCallback((ms: number) => { const totalSeconds = Math.floor(ms / 1000); @@ -960,6 +962,16 @@ export const Controls: FC = ({ router.back(); }, [router]); + const handleWillExit = useCallback(() => { + exitingRef.current = true; + setIsExiting(true); + }, []); + + const handleCancelExit = useCallback(() => { + exitingRef.current = false; + setIsExiting(false); + }, []); + const { isSliding: isRemoteSliding } = useRemoteControl({ showControls: showControls, toggleControls, @@ -976,6 +988,8 @@ export const Controls: FC = ({ onVerticalDpad: handleVerticalDpad, onHideControls: hideControls, onBack: handleBack, + onWillExit: handleWillExit, + onCancelExit: handleCancelExit, videoTitle: item?.Name ?? undefined, }); @@ -1061,6 +1075,7 @@ export const Controls: FC = ({ goToNextItemRef.current = goToNextItem; const handleAutoPlayFinish = useCallback(() => { + if (exitingRef.current) return; goToNextItem({ isAutoPlay: true }); }, [goToNextItem]); @@ -1135,7 +1150,7 @@ export const Controls: FC = ({ nextItem={nextItem} api={api} show={isCountdownActive} - isPlaying={isPlaying} + isPlaying={isPlaying && !isExiting} onFinish={handleAutoPlayFinish} onPlayNext={handleNextItemButton} controlsVisible={showControls} diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 359b4100d..8a4fd902e 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -35,6 +35,10 @@ interface UseRemoteControlProps { onLongSeekStop?: () => void; /** Callback when up/down D-pad pressed (to show controls with play button focused) */ onVerticalDpad?: () => void; + /** Called before the exit confirmation Alert is shown (e.g., to pause countdown) */ + onWillExit?: () => void; + /** Called when the user cancels the exit confirmation Alert */ + onCancelExit?: () => void; // Legacy props - kept for backwards compatibility with mobile Controls.tsx // These are ignored in the simplified implementation progress?: SharedValue; @@ -72,6 +76,8 @@ export function useRemoteControl({ onLongSeekRightStart, onLongSeekStop, onVerticalDpad, + onWillExit, + onCancelExit, }: UseRemoteControlProps) { // Keep these for backward compatibility with the component const remoteScrubProgress = useSharedValue(null); @@ -85,13 +91,24 @@ export function useRemoteControl({ const onHideControlsRef = useRef(onHideControls); const onBackRef = useRef(onBack); const videoTitleRef = useRef(videoTitle); + const onWillExitRef = useRef(onWillExit); + const onCancelExitRef = useRef(onCancelExit); useEffect(() => { showControlsRef.current = showControls; onHideControlsRef.current = onHideControls; onBackRef.current = onBack; videoTitleRef.current = videoTitle; - }, [showControls, onHideControls, onBack, videoTitle]); + onWillExitRef.current = onWillExit; + onCancelExitRef.current = onCancelExit; + }, [ + showControls, + onHideControls, + onBack, + videoTitle, + onWillExit, + onCancelExit, + ]); // BackHandler owns player exit: Android TV sends hardware back here, and // react-native-tvos maps the Apple TV menu button to the same API. @@ -102,6 +119,9 @@ export function useRemoteControl({ return true; } if (onBackRef.current) { + // Signal Controls that exit is imminent (pauses countdown, sets guard) + onWillExitRef.current?.(); + // Controls are hidden, so confirm before leaving playback. Alert.alert( "Stop Playback", @@ -109,7 +129,11 @@ export function useRemoteControl({ ? `Stop playing "${videoTitleRef.current}"?` : "Are you sure you want to stop playback?", [ - { text: "Cancel", style: "cancel" }, + { + text: "Cancel", + style: "cancel", + onPress: () => onCancelExitRef.current?.(), + }, { text: "Stop", style: "destructive", onPress: onBackRef.current }, ], );