From 4bef386b82a6c06c3244982fc47240a2e90eb226 Mon Sep 17 00:00:00 2001 From: Steve Byatt <47413006+stevebyatt10@users.noreply.github.com> Date: Wed, 20 May 2026 14:30:40 +0100 Subject: [PATCH] fix(tvOS): Patches for udp and screens menu button handling (#1564) --- bun-patches/react-native-screens@4.18.0.patch | 87 ++++++++++++ bun-patches/react-native-udp@4.1.7.patch | 17 +++ bun.lock | 4 + components/companion/CompanionLoginScreen.tsx | 33 ++++- components/login/TVAddServerForm.tsx | 131 +++--------------- components/login/TVAddUserForm.tsx | 105 ++------------ components/login/TVLogin.tsx | 3 + components/login/TVQRCodeDisplay.tsx | 104 ++------------ components/login/TVUserSelectionScreen.tsx | 36 ++--- hooks/useTVBackPress.ts | 17 ++- package.json | 4 + 11 files changed, 210 insertions(+), 331 deletions(-) create mode 100644 bun-patches/react-native-screens@4.18.0.patch create mode 100644 bun-patches/react-native-udp@4.1.7.patch diff --git a/bun-patches/react-native-screens@4.18.0.patch b/bun-patches/react-native-screens@4.18.0.patch new file mode 100644 index 00000000..7213178d --- /dev/null +++ b/bun-patches/react-native-screens@4.18.0.patch @@ -0,0 +1,87 @@ +diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm +index 47a671928338ae7fb4f85532d9fd1ed2d594f823..e4eecb7d5f9d3c3afc8a090fb953010e4e1b8a08 100644 +--- a/ios/RNSScreenStack.mm ++++ b/ios/RNSScreenStack.mm +@@ -34,6 +34,11 @@ + #import "integrations/RNSDismissibleModalProtocol.h" + #import "utils/UINavigationBar+RNSUtility.h" + ++#if TARGET_OS_TV ++#import ++#import ++#endif // TARGET_OS_TV ++ + #ifdef RNS_GAMMA_ENABLED + #import "RNSFrameCorrectionProvider.h" + #import "Swift-Bridging.h" +@@ -43,6 +48,12 @@ + namespace react = facebook::react; + #endif // RCT_NEW_ARCH_ENABLED + ++#if TARGET_OS_TV ++@interface RNSNavigationController () ++@property (nonatomic, strong) UITapGestureRecognizer *rnscreens_menuGestureRecognizer; ++@end ++#endif // TARGET_OS_TV ++ + @interface RNSScreenStackView () < + UINavigationControllerDelegate, + UIAdaptivePresentationControllerDelegate, +@@ -61,6 +72,57 @@ + @end + + @implementation RNSNavigationController ++ ++#if TARGET_OS_TV ++- (void)viewDidLoad ++{ ++ [super viewDidLoad]; ++ ++ self.rnscreens_menuGestureRecognizer = ++ [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rnscreens_menuPressed:)]; ++ self.rnscreens_menuGestureRecognizer.allowedPressTypes = @[ @(UIPressTypeMenu) ]; ++ ++ [[NSNotificationCenter defaultCenter] addObserver:self ++ selector:@selector(rnscreens_enableMenuGesture) ++ name:RCTTVEnableMenuKeyNotification ++ object:nil]; ++ [[NSNotificationCenter defaultCenter] addObserver:self ++ selector:@selector(rnscreens_disableMenuGesture) ++ name:RCTTVDisableMenuKeyNotification ++ object:nil]; ++ ++ if ([RCTTVRemoteHandler useMenuKey]) { ++ [self rnscreens_enableMenuGesture]; ++ } ++} ++ ++- (void)dealloc ++{ ++ [[NSNotificationCenter defaultCenter] removeObserver:self]; ++} ++ ++- (void)rnscreens_enableMenuGesture ++{ ++ if (![self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) { ++ [self.view addGestureRecognizer:self.rnscreens_menuGestureRecognizer]; ++ } ++} ++ ++- (void)rnscreens_disableMenuGesture ++{ ++ if ([self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) { ++ [self.view removeGestureRecognizer:self.rnscreens_menuGestureRecognizer]; ++ } ++} ++ ++- (void)rnscreens_menuPressed:(UIGestureRecognizer *)recognizer ++{ ++ [[NSNotificationCenter defaultCenter] postNavigationPressEventWithType:RCTTVRemoteEventMenu ++ keyAction:recognizer.eventKeyAction ++ tag:nil ++ target:nil]; ++} ++#endif // TARGET_OS_TV + + #if !TARGET_OS_TV + - (UIViewController *)childViewControllerForStatusBarStyle diff --git a/bun-patches/react-native-udp@4.1.7.patch b/bun-patches/react-native-udp@4.1.7.patch new file mode 100644 index 00000000..823acb86 --- /dev/null +++ b/bun-patches/react-native-udp@4.1.7.patch @@ -0,0 +1,17 @@ +diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/react-native-udp.podspec b/react-native-udp.podspec +index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644 +--- a/react-native-udp.podspec ++++ b/react-native-udp.podspec +@@ -9,7 +9,8 @@ Pod::Spec.new do |s| + s.homepage = package_json["homepage"] + s.license = package_json["license"] + s.author = { package_json["author"] => package_json["author"] } +- s.platform = :ios, "7.0" ++ s.ios.deployment_target = "7.0" ++ s.tvos.deployment_target = "15.1" + s.source = { :git => package_json["repository"]["url"], :tag => "v#{s.version}" } + s.source_files = 'ios/**/*.{h,m}' + s.dependency 'React-Core' diff --git a/bun.lock b/bun.lock index ab9acf3e..3d58370b 100644 --- a/bun.lock +++ b/bun.lock @@ -115,6 +115,10 @@ }, }, }, + "patchedDependencies": { + "react-native-screens@4.18.0": "bun-patches/react-native-screens@4.18.0.patch", + "react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch", + }, "overrides": { "expo-constants": "18.0.13", }, diff --git a/components/companion/CompanionLoginScreen.tsx b/components/companion/CompanionLoginScreen.tsx index 8ca1490e..95e92dff 100644 --- a/components/companion/CompanionLoginScreen.tsx +++ b/components/companion/CompanionLoginScreen.tsx @@ -1,4 +1,3 @@ -import { Camera, CameraView } from "expo-camera"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -30,13 +29,21 @@ interface ParsedPairingCode { code: string; } +type ExpoCameraModule = typeof import("expo-camera"); + +const ExpoCamera: ExpoCameraModule | null = Platform.isTV + ? null + : require("expo-camera"); + export const CompanionLoginScreen: React.FC = () => { const { t } = useTranslation(); const router = useRouter(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [screenState, setScreenState] = useState("scanning"); + const [screenState, setScreenState] = useState( + Platform.isTV ? "form" : "scanning", + ); const [pairingCode, setPairingCode] = useState(""); const [serverUrl, setServerUrl] = useState(""); const [username, setUsername] = useState(""); @@ -56,9 +63,11 @@ export const CompanionLoginScreen: React.FC = () => { // Request camera permission useEffect(() => { - Camera.getCameraPermissionsAsync().then((response) => { + if (!ExpoCamera) return; + + ExpoCamera.Camera.getCameraPermissionsAsync().then((response) => { if (!response.granted) { - Camera.requestCameraPermissionsAsync().then((result) => { + ExpoCamera.Camera.requestCameraPermissionsAsync().then((result) => { if (!result.granted) { setScreenState("no-permission"); } @@ -465,6 +474,22 @@ export const CompanionLoginScreen: React.FC = () => { ); } + const CameraView = ExpoCamera?.CameraView; + + if (!CameraView) { + return ( + + + + ); + } + return ( {/* Camera full screen */} diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx index ab74c55a..de28c64e 100644 --- a/components/login/TVAddServerForm.tsx +++ b/components/login/TVAddServerForm.tsx @@ -1,18 +1,10 @@ -import { Ionicons } from "@expo/vector-icons"; import { t } from "i18next"; -import React, { useEffect, useRef, useState } from "react"; -import { - Animated, - BackHandler, - Easing, - Platform, - Pressable, - ScrollView, - View, -} from "react-native"; +import React, { useCallback, useState } from "react"; +import { ScrollView, View } from "react-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVBackPress } from "@/hooks/useTVBackPress"; import { TVInput } from "./TVInput"; interface TVAddServerFormProps { @@ -23,68 +15,6 @@ interface TVAddServerFormProps { disabled?: boolean; } -const TVBackButton: React.FC<{ - onPress: () => void; - label: string; - disabled?: boolean; -}> = ({ onPress, label, disabled = false }) => { - const [isFocused, setIsFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateFocus = (focused: boolean) => { - Animated.timing(scale, { - toValue: focused ? 1.05 : 1, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - }; - - return ( - { - setIsFocused(true); - animateFocus(true); - }} - onBlur={() => { - setIsFocused(false); - animateFocus(false); - }} - style={{ alignSelf: "flex-start", marginBottom: 24 }} - disabled={disabled} - focusable={!disabled} - > - - - - {label} - - - - ); -}; - export const TVAddServerForm: React.FC = ({ onConnect, onStartPairing, @@ -103,23 +33,13 @@ export const TVAddServerForm: React.FC = ({ const isDisabled = disabled || loading; - // Handle Android TV back button, needed as an "override" - useEffect(() => { - if (!Platform.isTV) return; + const handleBack = useCallback(() => { + if (isDisabled) return false; + onBack(); + return true; + }, [isDisabled, onBack]); - const handleBackPress = () => { - if (disabled) return false; - onBack(); - return true; - }; - - const subscription = BackHandler.addEventListener( - "hardwareBackPress", - handleBackPress, - ); - - return () => subscription.remove(); - }, [onBack, disabled]); + useTVBackPress(() => handleBack(), [handleBack]); return ( = ({ paddingHorizontal: 60, }} > - {/* Back Button */} - + {/* Title */} + + {t("server.enter_url_to_jellyfin_server")} + {/* Server URL Input */} @@ -173,21 +100,9 @@ export const TVAddServerForm: React.FC = ({ - {/* Hint text */} - - {t("server.enter_url_to_jellyfin_server")} - - {/* Pair with Phone */} {onStartPairing && ( - +