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;
}