mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-21 22:36:36 +01:00
fix(tvOS): Patches for udp and screens menu button handling (#1564)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user