From 1cabbf087e351f956a0d850500f523ffb9ef9c34 Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Sat, 30 May 2026 10:03:19 +0200 Subject: [PATCH] fix: player getting stuck on timer and exit Fixed a race condition where the upnext countdown started and a user cancelled/stop the current playback that they would exit the player but the timer would still be running and then start playing the next episode and you wouldn't be able to press back or exit out of it Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- components/tv/TVNextEpisodeCountdown.tsx | 8 ++++-- .../video-player/controls/Controls.tv.tsx | 17 ++++++++++- .../controls/hooks/useRemoteControl.ts | 28 +++++++++++++++++-- 3 files changed, 48 insertions(+), 5 deletions(-) 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 }, ], );