diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 49fc16b6..f210ed95 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -43,6 +43,10 @@ import { type MpvPlayerViewRef, type MpvVideoSource, } from "@/modules"; +import { + isNativeTVControlsAvailable, + TVPlayerControlsView, +} from "@/modules/tv-player-controls"; import { useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem } from "@/providers/Downloads/types"; import { useInactivity } from "@/providers/InactivityProvider"; @@ -1189,37 +1193,87 @@ export default function page() { item && !isPipMode && (Platform.isTV ? ( - + // TV Controls: Use native SwiftUI controls if enabled and available, otherwise JS controls + settings.useNativeTVControls && + isNativeTVControlsAvailable() ? ( + seek(e.nativeEvent.positionMs)} + onSkipForward={() => { + const newPos = Math.min( + (item.RunTimeTicks ?? 0) / 10000, + progress.value + 30000, + ); + progress.value = newPos; + seek(newPos); + }} + onSkipBackward={() => { + const newPos = Math.max(0, progress.value - 10000); + progress.value = newPos; + seek(newPos); + }} + // Audio/subtitle settings will be handled in future iteration + // These would need the same modal hooks as the JS controls + onBack={() => router.back()} + onVisibilityChange={(e) => + setShowControls(e.nativeEvent.visible) + } + /> + ) : ( + + ) ) : ( void; /** Called when user presses the card to skip to next episode */ onPlayNext?: () => void; - /** Whether this card should capture focus when visible */ - hasFocus?: boolean; /** Whether controls are visible - affects card position */ controlsVisible?: boolean; } @@ -48,7 +46,6 @@ export const TVNextEpisodeCountdown: FC = ({ isPlaying, onFinish, onPlayNext, - hasFocus = false, controlsVisible = false, }) => { const typography = useScaledTVTypography(); @@ -141,7 +138,7 @@ export const TVNextEpisodeCountdown: FC = ({ onPress={onPlayNext} onFocus={handleFocus} onBlur={handleBlur} - hasTVPreferredFocus={hasFocus} + hasTVPreferredFocus={true} focusable={true} > diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx index f735e7d5..3e53d0f0 100644 --- a/components/tv/TVSkipSegmentCard.tsx +++ b/components/tv/TVSkipSegmentCard.tsx @@ -1,12 +1,8 @@ import { Ionicons } from "@expo/vector-icons"; -import { type FC, useEffect, useRef } from "react"; +import type { FC } from "react"; +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { - Pressable, - Animated as RNAnimated, - StyleSheet, - View, -} from "react-native"; +import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native"; import Animated, { Easing, useAnimatedStyle, @@ -20,8 +16,6 @@ export interface TVSkipSegmentCardProps { show: boolean; onPress: () => void; type: "intro" | "credits"; - /** Whether this card should capture focus when visible */ - hasFocus?: boolean; /** Whether controls are visible - affects card position */ controlsVisible?: boolean; } @@ -34,30 +28,15 @@ export const TVSkipSegmentCard: FC = ({ show, onPress, type, - hasFocus = false, controlsVisible = false, }) => { const { t } = useTranslation(); - const pressableRef = useRef(null); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.1, duration: 120, }); - // Programmatically request focus when card appears with hasFocus=true - useEffect(() => { - if (!show || !hasFocus || !pressableRef.current) return; - - const timer = setTimeout(() => { - // Use setNativeProps to trigger focus update on tvOS - (pressableRef.current as any)?.setNativeProps?.({ - hasTVPreferredFocus: true, - }); - }, 50); - return () => clearTimeout(timer); - }, [show, hasFocus]); - // Animated position based on controls visibility const bottomPosition = useSharedValue( controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS, @@ -88,11 +67,10 @@ export const TVSkipSegmentCard: FC = ({ pointerEvents='box-none' > = ({ max.value, ); - // Countdown logic - needs to be early so toggleControls can reference it + // Countdown logic const isCountdownActive = useMemo(() => { if (!nextItem) return false; if (item?.Type !== "Episode") return false; return remainingTime > 0 && remainingTime <= 10000; }, [nextItem, item, remainingTime]); - // Whether any skip card is visible - used to prevent focus conflicts - const isSkipCardVisible = - (showSkipButton && !isCountdownActive) || - (showSkipCreditButton && + // Simple boolean - when skip cards or countdown are visible, they have focus + const isSkipOrCountdownVisible = useMemo(() => { + const skipIntroVisible = showSkipButton && !isCountdownActive; + const skipCreditsVisible = + showSkipCreditButton && (hasContentAfterCredits || !nextItem) && - !isCountdownActive); - - // Brief delay to ignore focus events when countdown first appears - const countdownJustActivatedRef = useRef(false); - - useEffect(() => { - if (!isCountdownActive) { - countdownJustActivatedRef.current = false; - return; - } - countdownJustActivatedRef.current = true; - const timeout = setTimeout(() => { - countdownJustActivatedRef.current = false; - }, 200); - return () => clearTimeout(timeout); - }, [isCountdownActive]); - - // Brief delay to ignore focus events when skip card first appears - const skipCardJustActivatedRef = useRef(false); - - useEffect(() => { - if (!isSkipCardVisible) { - skipCardJustActivatedRef.current = false; - return; - } - skipCardJustActivatedRef.current = true; - const timeout = setTimeout(() => { - skipCardJustActivatedRef.current = false; - }, 200); - return () => clearTimeout(timeout); - }, [isSkipCardVisible]); - - // Brief delay to ignore focus events after pressing skip button - const skipJustPressedRef = useRef(false); - - // Wrapper to prevent focus events after skip actions - const handleSkipWithDelay = useCallback((skipFn: () => void) => { - skipJustPressedRef.current = true; - skipFn(); - setTimeout(() => { - skipJustPressedRef.current = false; - }, 500); - }, []); - - const handleSkipIntro = useCallback(() => { - handleSkipWithDelay(skipIntro); - }, [handleSkipWithDelay, skipIntro]); - - const handleSkipCredit = useCallback(() => { - handleSkipWithDelay(skipCredit); - }, [handleSkipWithDelay, skipCredit]); + !isCountdownActive; + return skipIntroVisible || skipCreditsVisible || isCountdownActive; + }, [ + showSkipButton, + showSkipCreditButton, + hasContentAfterCredits, + nextItem, + isCountdownActive, + ]); // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels) const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel"; @@ -507,14 +466,9 @@ export const Controls: FC = ({ }; const toggleControls = useCallback(() => { - // Skip if countdown or skip card just became active (ignore initial focus event) - const shouldIgnore = - countdownJustActivatedRef.current || - skipCardJustActivatedRef.current || - skipJustPressedRef.current; - if (shouldIgnore) return; + if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't toggle setShowControls(!showControls); - }, [showControls, setShowControls]); + }, [showControls, setShowControls, isSkipOrCountdownVisible]); const [showSeekBubble, setShowSeekBubble] = useState(false); const [seekBubbleTime, setSeekBubbleTime] = useState({ @@ -942,18 +896,22 @@ export const Controls: FC = ({ // Callback for up/down D-pad - show controls with play button focused const handleVerticalDpad = useCallback(() => { - // Skip if countdown or skip card just became active (ignore initial focus event) - const shouldIgnore = - countdownJustActivatedRef.current || - skipCardJustActivatedRef.current || - skipJustPressedRef.current; - if (shouldIgnore) return; + if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't show controls setFocusPlayButton(true); setShowControls(true); + }, [setShowControls, isSkipOrCountdownVisible]); + + const hideControls = useCallback(() => { + setShowControls(false); + setFocusPlayButton(false); }, [setShowControls]); + const handleBack = useCallback(() => { + router.back(); + }, [router]); + const { isSliding: isRemoteSliding } = useRemoteControl({ - showControls, + showControls: showControls, toggleControls, togglePlay, isProgressBarFocused, @@ -966,15 +924,13 @@ export const Controls: FC = ({ onLongSeekRightStart: handleDpadLongSeekForward, onLongSeekStop: stopContinuousSeeking, onVerticalDpad: handleVerticalDpad, + onHideControls: hideControls, + onBack: handleBack, + videoTitle: item?.Name ?? undefined, }); - const hideControls = useCallback(() => { - setShowControls(false); - setFocusPlayButton(false); - }, [setShowControls]); - const { handleControlsInteraction } = useControlsTimeout({ - showControls, + showControls: showControls, isSliding: isRemoteSliding, episodeView: false, onHideControls: hideControls, @@ -1081,9 +1037,8 @@ export const Controls: FC = ({ {/* Skip intro card */} @@ -1094,14 +1049,8 @@ export const Controls: FC = ({ (hasContentAfterCredits || !nextItem) && !isCountdownActive } - onPress={handleSkipCredit} + onPress={skipCredit} type='credits' - hasFocus={ - showSkipCreditButton && - (hasContentAfterCredits || !nextItem) && - !isCountdownActive && - !showSkipButton - } controlsVisible={showControls} /> @@ -1113,7 +1062,6 @@ export const Controls: FC = ({ isPlaying={isPlaying} onFinish={handleAutoPlayFinish} onPlayNext={handleNextItemButton} - hasFocus={isCountdownActive} controlsVisible={showControls} /> )} @@ -1215,7 +1163,7 @@ export const Controls: FC = ({ = ({ onFocus={() => setIsProgressBarFocused(true)} onBlur={() => setIsProgressBarFocused(false)} refSetter={setProgressBarRef} - hasTVPreferredFocus={ - !isCountdownActive && - !isSkipCardVisible && - lastOpenedModal === null && - !focusPlayButton - } + hasTVPreferredFocus={false} /> diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index f30fda23..513c8dd7 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { Platform } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { Alert, BackHandler, Platform } from "react-native"; import { type SharedValue, useSharedValue } from "react-native-reanimated"; // TV event handler with fallback for non-TV platforms @@ -23,6 +23,10 @@ interface UseRemoteControlProps { disableSeeking?: boolean; /** Callback for back/menu button press (tvOS: menu, Android TV: back) */ onBack?: () => void; + /** Callback to hide controls (called on back press when controls are visible) */ + onHideControls?: () => void; + /** Title of the video being played (shown in exit confirmation) */ + videoTitle?: string; /** Whether the progress bar currently has focus */ isProgressBarFocused?: boolean; /** Callback for seeking left when progress bar is focused */ @@ -69,6 +73,8 @@ export function useRemoteControl({ toggleControls, togglePlay, onBack, + onHideControls, + videoTitle, isProgressBarFocused, onSeekLeft, onSeekRight, @@ -87,14 +93,73 @@ export function useRemoteControl({ const [isSliding] = useState(false); const [time] = useState({ hours: 0, minutes: 0, seconds: 0 }); + // Use refs to avoid stale closures in BackHandler + const showControlsRef = useRef(showControls); + const onHideControlsRef = useRef(onHideControls); + const onBackRef = useRef(onBack); + const videoTitleRef = useRef(videoTitle); + + useEffect(() => { + showControlsRef.current = showControls; + onHideControlsRef.current = onHideControls; + onBackRef.current = onBack; + videoTitleRef.current = videoTitle; + }, [showControls, onHideControls, onBack, videoTitle]); + + // Handle hardware back button (works on both Android TV and tvOS) + useEffect(() => { + if (!Platform.isTV) return; + + const handleBackPress = () => { + if (showControlsRef.current && onHideControlsRef.current) { + // Controls are visible - just hide them + onHideControlsRef.current(); + return true; // Prevent default back navigation + } + if (onBackRef.current) { + // Controls are hidden - show confirmation before exiting + Alert.alert( + "Stop Playback", + videoTitleRef.current + ? `Stop playing "${videoTitleRef.current}"?` + : "Are you sure you want to stop playback?", + [ + { text: "Cancel", style: "cancel" }, + { text: "Stop", style: "destructive", onPress: onBackRef.current }, + ], + ); + return true; // Prevent default back navigation + } + return false; // Let default back navigation happen + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, []); + // TV remote control handling (no-op on non-TV platforms) useTVEventHandler((evt) => { if (!evt) return; - // Handle back/menu button press (tvOS: menu, Android TV: back) - if (evt.eventType === "menu" || evt.eventType === "back") { - if (onBack) { - onBack(); + // Back/menu is handled by BackHandler above, but keep this for tvOS menu button + if (evt.eventType === "menu") { + if (showControls && onHideControls) { + onHideControls(); + } else if (onBack) { + Alert.alert( + "Stop Playback", + videoTitle + ? `Stop playing "${videoTitle}"?` + : "Are you sure you want to stop playback?", + [ + { text: "Cancel", style: "cancel" }, + { text: "Stop", style: "destructive", onPress: onBack }, + ], + ); } return; } @@ -154,8 +219,8 @@ export function useRemoteControl({ onVerticalDpad(); return; } - // For other D-pad presses, show full controls - toggleControls(); + // Ignore all other events (focus/blur, swipes, etc.) + // User can press up/down to show controls return; }