fix(tvOS): Patches for udp and screens menu button handling (#1564)

This commit is contained in:
Steve Byatt
2026-05-20 14:30:40 +01:00
committed by GitHub
parent e84cea6427
commit 4bef386b82
11 changed files with 210 additions and 331 deletions

View File

@@ -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 */}

View File

@@ -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'

View File

@@ -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={{

View File

@@ -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);

View File

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

View File

@@ -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