From ca4f24ded0381c3803cf1b11b1b6e61117138dbc Mon Sep 17 00:00:00 2001 From: Steve Byatt <47413006+stevebyatt10@users.noreply.github.com> Date: Wed, 20 May 2026 08:57:19 +0100 Subject: [PATCH] fix: handle TV menu and back navigation (#1559) --- app/(auth)/(tabs)/_layout.tsx | 4 +- components/login/TVUserSelectionScreen.tsx | 13 +- .../controls/hooks/useRemoteControl.ts | 92 ++++--------- hooks/useTVBackHandler.ts | 129 +++--------------- hooks/useTVBackPress.ts | 57 ++++++++ hooks/useTVEventHandler.ts | 17 +++ 6 files changed, 123 insertions(+), 189 deletions(-) create mode 100644 hooks/useTVBackPress.ts create mode 100644 hooks/useTVEventHandler.ts diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index b92f3b13..29e706ad 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -12,7 +12,7 @@ import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { Colors } from "@/constants/Colors"; -import { useTVBackHandler } from "@/hooks/useTVBackHandler"; +import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; @@ -38,7 +38,7 @@ export default function TabLayout() { const { t } = useTranslation(); // Handle TV back button - prevent app exit when at root - useTVBackHandler(); + useTVHomeBackHandler(); return ( diff --git a/components/login/TVUserSelectionScreen.tsx b/components/login/TVUserSelectionScreen.tsx index d9f74b37..97c269cf 100644 --- a/components/login/TVUserSelectionScreen.tsx +++ b/components/login/TVUserSelectionScreen.tsx @@ -3,6 +3,7 @@ import React, { useEffect } from "react"; import { BackHandler, Platform, ScrollView, View } from "react-native"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVEventHandler } from "@/hooks/useTVEventHandler"; import type { SavedServer, SavedServerAccount, @@ -19,18 +20,6 @@ interface TVUserSelectionScreenProps { disabled?: boolean; } -// TV event handler with fallback for non-TV platforms -let useTVEventHandler: (callback: (evt: any) => void) => void; -if (Platform.isTV) { - try { - useTVEventHandler = require("react-native").useTVEventHandler; - } catch { - useTVEventHandler = () => {}; - } -} else { - useTVEventHandler = () => {}; -} - export const TVUserSelectionScreen: React.FC = ({ server, onUserSelect, diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 513c8dd7..359b4100 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -1,20 +1,8 @@ import { useEffect, useRef, useState } from "react"; -import { Alert, BackHandler, Platform } from "react-native"; +import { Alert } from "react-native"; import { type SharedValue, useSharedValue } from "react-native-reanimated"; - -// TV event handler with fallback for non-TV platforms -let useTVEventHandler: (callback: (evt: any) => void) => void; -if (Platform.isTV) { - try { - useTVEventHandler = require("react-native").useTVEventHandler; - } catch { - // Fallback for non-TV platforms - useTVEventHandler = () => {}; - } -} else { - // No-op hook for non-TV platforms - useTVEventHandler = () => {}; -} +import { useTVBackPress } from "@/hooks/useTVBackPress"; +import { useTVEventHandler } from "@/hooks/useTVEventHandler"; interface UseRemoteControlProps { showControls: boolean; @@ -70,7 +58,6 @@ interface UseRemoteControlProps { */ export function useRemoteControl({ showControls, - toggleControls, togglePlay, onBack, onHideControls, @@ -106,61 +93,38 @@ export function useRemoteControl({ 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(); + // BackHandler owns player exit: Android TV sends hardware back here, and + // react-native-tvos maps the Apple TV menu button to the same API. + useTVBackPress(() => { + if (showControlsRef.current && onHideControlsRef.current) { + // Controls are visible, so the first back press only hides them. + onHideControlsRef.current(); + return true; + } + if (onBackRef.current) { + // Controls are hidden, so confirm before leaving playback. + 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; + } + return false; }, []); // TV remote control handling (no-op on non-TV platforms) useTVEventHandler((evt) => { if (!evt) return; - // Back/menu is handled by BackHandler above, but keep this for tvOS menu button + // Back/menu is handled by useTVBackPress above. Keep this handler focused + // on remote-control events like play/pause, D-pad, and long seek. 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; } diff --git a/hooks/useTVBackHandler.ts b/hooks/useTVBackHandler.ts index dd5ca106..8277d0a7 100644 --- a/hooks/useTVBackHandler.ts +++ b/hooks/useTVBackHandler.ts @@ -1,25 +1,12 @@ -import { useNavigation } from "@react-navigation/native"; -import { router, useSegments } from "expo-router"; -import { useEffect, useRef } from "react"; -import { BackHandler, Platform } from "react-native"; +import { useSegments } from "expo-router"; +import { useEffect } from "react"; +import { Platform } from "react-native"; +import { + disableTVMenuKeyInterception, + enableTVMenuKeyInterception, +} from "./useTVBackPress"; -// TV event handler and control with fallback for non-TV platforms -let useTVEventHandler: (callback: (evt: any) => void) => void; -let TVEventControl: { - enableTVMenuKey: () => void; - disableTVMenuKey: () => void; -} | null = null; - -if (Platform.isTV) { - try { - useTVEventHandler = require("react-native").useTVEventHandler; - TVEventControl = require("react-native").TVEventControl; - } catch { - useTVEventHandler = () => {}; - } -} else { - useTVEventHandler = () => {}; -} +export { enableTVMenuKeyInterception } from "./useTVBackPress"; /** * Check if we're at the root of a tab @@ -55,106 +42,26 @@ function getCurrentTab(segments: string[]): string | undefined { } /** - * Hook to handle TV back/menu button presses. - * - * Behavior: - * - On home tab at root: allows app to exit (default tvOS behavior) - * - On other tabs at root: navigates to home tab - * - Deeper in navigation stack: goes back + * Keeps tvOS menu key interception disabled on the home tab root so the system + * can apply its native app-exit behavior. Other routes can opt into + * interception when they need JS-owned back handling. */ -export function useTVBackHandler() { - const navigation = useNavigation(); +export function useTVHomeBackHandler() { const segments = useSegments(); - const lastMenuKeyState = useRef(null); // Get current state const currentTab = getCurrentTab(segments); const atTabRoot = isAtTabRoot(segments); const isOnHomeRoot = atTabRoot && currentTab === "(home)"; - // Toggle menu key interception based on current location - useEffect(() => { - if (!Platform.isTV || !TVEventControl) return; - - if (isOnHomeRoot) { - // On home tab root - disable interception to allow app exit - if (lastMenuKeyState.current !== false) { - TVEventControl.disableTVMenuKey(); - lastMenuKeyState.current = false; - } - } else { - // On other screens - enable interception to handle navigation - if (lastMenuKeyState.current !== true) { - TVEventControl.enableTVMenuKey(); - lastMenuKeyState.current = true; - } - } - }, [isOnHomeRoot]); - - // Handle TV remote menu/back button events - useTVEventHandler((evt) => { - if (!evt) return; - if (evt.eventType === "menu" || evt.eventType === "back") { - // If on home root, let the default behavior happen (app exit) - if (isOnHomeRoot) { - return; - } - - // If at tab root level (but not home), navigate to home - if (atTabRoot) { - router.navigate("/(auth)/(tabs)/(home)"); - return; - } - - // Not at tab root - go back in the stack - if (navigation.canGoBack()) { - navigation.goBack(); - return; - } - - // Fallback: navigate to home - router.navigate("/(auth)/(tabs)/(home)"); - } - }); - - // Android TV BackHandler useEffect(() => { if (!Platform.isTV) return; - const handleBackPress = () => { - // If on home root, allow app to exit - if (isOnHomeRoot) { - return false; // Don't prevent default (allows exit) - } + if (isOnHomeRoot) { + disableTVMenuKeyInterception(); + return; + } - if (atTabRoot) { - router.navigate("/(auth)/(tabs)/(home)"); - return true; - } - - if (navigation.canGoBack()) { - navigation.goBack(); - return true; - } - - router.navigate("/(auth)/(tabs)/(home)"); - return true; - }; - - const subscription = BackHandler.addEventListener( - "hardwareBackPress", - handleBackPress, - ); - - return () => subscription.remove(); - }, [navigation, isOnHomeRoot, atTabRoot]); -} - -/** - * Call this at app startup to enable TV menu key interception. - */ -export function enableTVMenuKeyInterception() { - if (Platform.isTV && TVEventControl) { - TVEventControl.enableTVMenuKey(); - } + enableTVMenuKeyInterception(); + }, [isOnHomeRoot]); } diff --git a/hooks/useTVBackPress.ts b/hooks/useTVBackPress.ts new file mode 100644 index 00000000..18a11407 --- /dev/null +++ b/hooks/useTVBackPress.ts @@ -0,0 +1,57 @@ +import { type DependencyList, useEffect } from "react"; +import { BackHandler, Platform } from "react-native"; + +type TVBackPressHandler = () => boolean | null | undefined; + +let TVEventControl: { + enableTVMenuKey: () => void; + disableTVMenuKey: () => void; +} | null = null; + +if (Platform.isTV) { + try { + TVEventControl = require("react-native").TVEventControl; + } catch { + TVEventControl = null; + } +} + +export function enableTVMenuKeyInterception() { + if (Platform.isTV && TVEventControl) { + TVEventControl.enableTVMenuKey(); + } +} + +export function disableTVMenuKeyInterception() { + if (Platform.isTV && TVEventControl) { + TVEventControl.disableTVMenuKey(); + } +} + +/** + * Subscribe to TV back presses through React Native's BackHandler. + * + * On Android TV this handles the hardware back button. On tvOS, + * react-native-tvos maps the Apple TV menu button to the same API when menu key + * interception is enabled. + * + * @see https://reactnative.dev/docs/backhandler + */ +export function useTVBackPress( + handler: TVBackPressHandler, + deps: DependencyList, +) { + useEffect(() => { + if (!Platform.isTV) return; + + // BackHandler is the shared back/menu surface for TV platforms: + // Android TV sends hardware back here, and react-native-tvos sends menu + // here when menu key interception is enabled. + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handler, + ); + + return () => subscription.remove(); + }, deps); +} diff --git a/hooks/useTVEventHandler.ts b/hooks/useTVEventHandler.ts new file mode 100644 index 00000000..d92011b7 --- /dev/null +++ b/hooks/useTVEventHandler.ts @@ -0,0 +1,17 @@ +import type { HWEvent } from "react-native"; +import { Platform } from "react-native"; + +type UseTVEventHandler = (callback: (evt: HWEvent) => void) => void; + +let tvEventHandler: UseTVEventHandler = () => {}; + +if (Platform.isTV) { + try { + tvEventHandler = require("react-native") + .useTVEventHandler as UseTVEventHandler; + } catch { + tvEventHandler = () => {}; + } +} + +export const useTVEventHandler = tvEventHandler;