mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-20 22:06:35 +01:00
fix: handle TV menu and back navigation (#1559)
This commit is contained in:
@@ -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 (
|
||||
<View style={{ flex: 1 }}>
|
||||
|
||||
@@ -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<TVUserSelectionScreenProps> = ({
|
||||
server,
|
||||
onUserSelect,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<any>();
|
||||
export function useTVHomeBackHandler() {
|
||||
const segments = useSegments();
|
||||
const lastMenuKeyState = useRef<boolean | null>(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]);
|
||||
}
|
||||
|
||||
57
hooks/useTVBackPress.ts
Normal file
57
hooks/useTVBackPress.ts
Normal file
@@ -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);
|
||||
}
|
||||
17
hooks/useTVEventHandler.ts
Normal file
17
hooks/useTVEventHandler.ts
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user