mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-21 14:26:35 +01:00
fix(tvOS): Patches for udp and screens menu button handling (#1564)
This commit is contained in:
87
bun-patches/react-native-screens@4.18.0.patch
Normal file
87
bun-patches/react-native-screens@4.18.0.patch
Normal file
@@ -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 <React/RCTTVNavigationEventNotification.h>
|
||||
+#import <React/RCTTVRemoteHandler.h>
|
||||
+#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
|
||||
17
bun-patches/react-native-udp@4.1.7.patch
Normal file
17
bun-patches/react-native-udp@4.1.7.patch
Normal file
@@ -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'
|
||||
4
bun.lock
4
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",
|
||||
},
|
||||
|
||||
@@ -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<ScreenState>("scanning");
|
||||
const [screenState, setScreenState] = useState<ScreenState>(
|
||||
Platform.isTV ? "form" : "scanning",
|
||||
);
|
||||
const [pairingCode, setPairingCode] = useState<string>("");
|
||||
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 (
|
||||
<View className='flex-1 bg-black items-center justify-center p-8'>
|
||||
<Button
|
||||
onPress={handleEnterCodeManually}
|
||||
color='purple'
|
||||
textClassName='flex-1 text-center'
|
||||
>
|
||||
{t("companion_login.enter_code_manually")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='flex-1 bg-black items-center justify-center'>
|
||||
{/* Camera full screen */}
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
animateFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
}}
|
||||
style={{ alignSelf: "flex-start", marginBottom: 24 }}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={28}
|
||||
color={isFocused ? "#000" : "#fff"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: isFocused ? "#000" : "#fff",
|
||||
fontSize: 20,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
onConnect,
|
||||
onStartPairing,
|
||||
@@ -103,23 +33,13 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
|
||||
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 (
|
||||
<ScrollView
|
||||
@@ -139,12 +59,19 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Back Button */}
|
||||
<TVBackButton
|
||||
onPress={onBack}
|
||||
label={t("common.back")}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
textAlign: "left",
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 8,
|
||||
}}
|
||||
>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
|
||||
{/* Server URL Input */}
|
||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||
@@ -173,21 +100,9 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Hint text */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#6B7280",
|
||||
textAlign: "left",
|
||||
paddingHorizontal: 8,
|
||||
}}
|
||||
>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
|
||||
{/* Pair with Phone */}
|
||||
{onStartPairing && (
|
||||
<View style={{ marginTop: 32 }}>
|
||||
<View>
|
||||
<Button
|
||||
onPress={onStartPairing}
|
||||
className='bg-neutral-800 border border-neutral-700'
|
||||
|
||||
@@ -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";
|
||||
import { TVSaveAccountToggle } from "./TVSaveAccountToggle";
|
||||
|
||||
@@ -30,68 +22,6 @@ interface TVAddUserFormProps {
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
animateFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
}}
|
||||
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={28}
|
||||
color={isFocused ? "#000" : "#fff"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: isFocused ? "#000" : "#fff",
|
||||
fontSize: 20,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
|
||||
serverName,
|
||||
serverAddress,
|
||||
@@ -116,23 +46,13 @@ export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
|
||||
|
||||
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 (
|
||||
<ScrollView
|
||||
@@ -152,13 +72,6 @@ export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Back Button */}
|
||||
<TVBackButton
|
||||
onPress={onBack}
|
||||
label={t("common.back")}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVMenuKeyInterception } from "@/hooks/useTVBackPress";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
@@ -77,6 +78,8 @@ export const TVLogin: React.FC = () => {
|
||||
// Current screen state
|
||||
const [currentScreen, setCurrentScreen] =
|
||||
useState<TVLoginScreen>("server-selection");
|
||||
// No interception on server-selection so that it can go back to home screen on tvOS
|
||||
useTVMenuKeyInterception(currentScreen !== "server-selection");
|
||||
|
||||
// Current selected server for user selection screen
|
||||
const [currentServer, setCurrentServer] = useState<SavedServer | null>(null);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { t } from "i18next";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import {
|
||||
Animated,
|
||||
BackHandler,
|
||||
Easing,
|
||||
Platform,
|
||||
Pressable,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useCallback } from "react";
|
||||
import { View } from "react-native";
|
||||
import QRCode from "react-native-qrcode-svg";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
interface TVQRCodeDisplayProps {
|
||||
@@ -24,7 +17,6 @@ export const TVQRCodeDisplay: React.FC<TVQRCodeDisplayProps> = ({
|
||||
onBack,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const handledRef = useRef(false);
|
||||
|
||||
const qrSize = scaleSize(280);
|
||||
const cardPadding = scaleSize(16);
|
||||
@@ -36,30 +28,14 @@ export const TVQRCodeDisplay: React.FC<TVQRCodeDisplayProps> = ({
|
||||
code,
|
||||
});
|
||||
|
||||
// Handle Android TV back button
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV || !onBack) return;
|
||||
|
||||
const handleBackPress = () => {
|
||||
if (handledRef.current) return true;
|
||||
|
||||
handledRef.current = true;
|
||||
setTimeout(() => {
|
||||
handledRef.current = false;
|
||||
}, 100);
|
||||
|
||||
onBack();
|
||||
return true;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
handleBackPress,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
const handleBack = useCallback(() => {
|
||||
if (!onBack) return false;
|
||||
onBack();
|
||||
return true;
|
||||
}, [onBack]);
|
||||
|
||||
useTVBackPress(() => handleBack(), [handleBack]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -75,9 +51,6 @@ export const TVQRCodeDisplay: React.FC<TVQRCodeDisplayProps> = ({
|
||||
paddingHorizontal: outerPadding,
|
||||
}}
|
||||
>
|
||||
{/* Back Button */}
|
||||
{onBack && <TVBackButton onPress={onBack} />}
|
||||
|
||||
{/* QR Code */}
|
||||
<View
|
||||
style={{
|
||||
@@ -140,62 +113,3 @@ export const TVQRCodeDisplay: React.FC<TVQRCodeDisplayProps> = ({
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const TVBackButton: React.FC<{
|
||||
onPress: () => void;
|
||||
}> = ({ onPress }) => {
|
||||
const [isFocused, setIsFocused] = React.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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
animateFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
}}
|
||||
style={{ alignSelf: "flex-start", marginBottom: 24 }}
|
||||
focusable
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={28}
|
||||
color={isFocused ? "#000" : "#fff"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: isFocused ? "#000" : "#fff",
|
||||
fontSize: 20,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { t } from "i18next";
|
||||
import React, { useEffect } from "react";
|
||||
import { BackHandler, Platform, ScrollView, View } from "react-native";
|
||||
import React, { useCallback } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||
import type {
|
||||
SavedServer,
|
||||
SavedServerAccount,
|
||||
@@ -32,31 +32,13 @@ export const TVUserSelectionScreen: React.FC<TVUserSelectionScreenProps> = ({
|
||||
const accounts = server.accounts || [];
|
||||
const hasAccounts = accounts.length > 0;
|
||||
|
||||
// Handle TV remote back/menu button
|
||||
useTVEventHandler((evt) => {
|
||||
if (!evt || disabled) return;
|
||||
if (evt.eventType === "menu" || evt.eventType === "back") {
|
||||
onChangeServer();
|
||||
}
|
||||
});
|
||||
const handleBackPress = useCallback(() => {
|
||||
if (disabled) return false;
|
||||
onChangeServer();
|
||||
return true;
|
||||
}, [disabled, onChangeServer]);
|
||||
|
||||
// Handle Android TV back button
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) return;
|
||||
|
||||
const handleBackPress = () => {
|
||||
if (disabled) return false;
|
||||
onChangeServer();
|
||||
return true;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
handleBackPress,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [onChangeServer, disabled]);
|
||||
useTVBackPress(handleBackPress, [handleBackPress]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
|
||||
@@ -28,6 +28,19 @@ export function disableTVMenuKeyInterception() {
|
||||
}
|
||||
}
|
||||
|
||||
export function useTVMenuKeyInterception(enabled = true) {
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) return;
|
||||
|
||||
if (enabled) {
|
||||
enableTVMenuKeyInterception();
|
||||
return;
|
||||
}
|
||||
|
||||
disableTVMenuKeyInterception();
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to TV back presses through React Native's BackHandler.
|
||||
*
|
||||
@@ -52,6 +65,8 @@ export function useTVBackPress(
|
||||
handler,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,10 @@
|
||||
"trustedDependencies": [
|
||||
"unrs-resolver"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"resolutions": {
|
||||
"expo-constants": "18.0.13"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user