mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-20 22:06:35 +01:00
Adding QR code login (#1557)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
507
components/companion/CompanionLoginScreen.tsx
Normal file
507
components/companion/CompanionLoginScreen.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
import { Camera, CameraView } from "expo-camera";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Linking,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { sendCredentialsToTV } from "@/utils/pairingService";
|
||||
|
||||
type ScreenState =
|
||||
| "scanning"
|
||||
| "no-permission"
|
||||
| "confirm"
|
||||
| "form"
|
||||
| "sending"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
interface ParsedPairingCode {
|
||||
code: string;
|
||||
}
|
||||
|
||||
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 [pairingCode, setPairingCode] = useState<string>("");
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Pre-fill server URL and username from current session
|
||||
useEffect(() => {
|
||||
if (api?.basePath) {
|
||||
setServerUrl(api.basePath);
|
||||
}
|
||||
|
||||
if (user?.Name) {
|
||||
setUsername(user.Name);
|
||||
}
|
||||
}, [api?.basePath, user?.Name]);
|
||||
|
||||
// Request camera permission
|
||||
useEffect(() => {
|
||||
Camera.getCameraPermissionsAsync().then((response) => {
|
||||
if (!response.granted) {
|
||||
Camera.requestCameraPermissionsAsync().then((result) => {
|
||||
if (!result.granted) {
|
||||
setScreenState("no-permission");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const validateAndParseQR = useCallback(
|
||||
(data: string): ParsedPairingCode | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
if (
|
||||
parsed.action === "streamyfin-pair" &&
|
||||
typeof parsed.code === "string" &&
|
||||
parsed.code.length > 0
|
||||
) {
|
||||
return { code: parsed.code };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBarCodeScanned = useCallback(
|
||||
({ data }: { data: string }) => {
|
||||
if (screenState !== "scanning") return;
|
||||
|
||||
const parsed = validateAndParseQR(data);
|
||||
|
||||
if (!parsed) {
|
||||
setErrorMessage(t("companion_login.error_invalid_qr"));
|
||||
setScreenState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setPairingCode(parsed.code);
|
||||
|
||||
// If user is logged in, show confirmation screen (still needs password)
|
||||
// Otherwise, go straight to the full form
|
||||
if (user?.Name && api?.basePath) {
|
||||
setScreenState("confirm");
|
||||
} else {
|
||||
setScreenState("form");
|
||||
}
|
||||
},
|
||||
[screenState, validateAndParseQR, t, user?.Name, api?.basePath],
|
||||
);
|
||||
|
||||
const handleSendCredentials = useCallback(async () => {
|
||||
if (
|
||||
!serverUrl.trim() ||
|
||||
!username.trim() ||
|
||||
!password.trim() ||
|
||||
!pairingCode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScreenState("sending");
|
||||
|
||||
try {
|
||||
await sendCredentialsToTV(
|
||||
pairingCode,
|
||||
serverUrl.trim(),
|
||||
username.trim(),
|
||||
password,
|
||||
);
|
||||
|
||||
setScreenState("success");
|
||||
} catch {
|
||||
setErrorMessage(t("companion_login.error_generic"));
|
||||
setScreenState("error");
|
||||
}
|
||||
}, [pairingCode, serverUrl, username, password, t]);
|
||||
|
||||
const handleScanAgain = useCallback(() => {
|
||||
setPairingCode("");
|
||||
setErrorMessage(null);
|
||||
setPassword("");
|
||||
setScreenState("scanning");
|
||||
}, []);
|
||||
|
||||
const handleDone = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleUseDifferentUser = useCallback(() => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setScreenState("form");
|
||||
}, []);
|
||||
|
||||
const handleEnterCodeManually = useCallback(() => {
|
||||
setScreenState("form");
|
||||
}, []);
|
||||
|
||||
if (screenState === "no-permission") {
|
||||
return (
|
||||
<View className='flex-1 bg-black'>
|
||||
<View className='flex-1 items-center justify-center p-8'>
|
||||
<Text className='mb-3 text-center text-3xl font-bold text-white'>
|
||||
{t("companion_login.error_permission_denied")}
|
||||
</Text>
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => Linking.openSettings()}
|
||||
className='mt-4 rounded-lg bg-purple-600 px-6 py-3'
|
||||
>
|
||||
<Text className='text-base font-semibold text-white'>
|
||||
{t("companion_login.open_settings")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onPress={handleDone}
|
||||
color='white'
|
||||
className='mt-4'
|
||||
textClassName='flex-1 text-center'
|
||||
>
|
||||
{t("companion_login.done")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (screenState === "success") {
|
||||
return (
|
||||
<View className='flex-1 bg-black'>
|
||||
<View className='flex-1 items-center justify-center p-8'>
|
||||
<Text className='mb-3 text-center text-3xl font-bold text-white'>
|
||||
{t("companion_login.success_title")}
|
||||
</Text>
|
||||
|
||||
<Text className='mb-8 text-center text-base text-gray-400'>
|
||||
{t("companion_login.pairing_tv_connecting")}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
onPress={handleDone}
|
||||
color='purple'
|
||||
textClassName='flex-1 text-center'
|
||||
>
|
||||
{t("companion_login.done")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (screenState === "error") {
|
||||
return (
|
||||
<View className='flex-1 bg-black'>
|
||||
<View className='flex-1 items-center justify-center p-8'>
|
||||
<Text className='mb-3 text-center text-3xl font-bold text-white'>
|
||||
{t("companion_login.error_title")}
|
||||
</Text>
|
||||
|
||||
<Text className='mb-8 text-center text-base text-gray-400'>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
|
||||
<View className='mt-4 flex-row gap-3'>
|
||||
<Button
|
||||
onPress={handleScanAgain}
|
||||
color='purple'
|
||||
textClassName='flex-1 text-center'
|
||||
>
|
||||
{t("companion_login.scan_again")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={handleDone}
|
||||
color='white'
|
||||
textClassName='flex-1 text-center'
|
||||
>
|
||||
{t("companion_login.done")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (screenState === "sending") {
|
||||
return (
|
||||
<View className='flex-1 bg-black'>
|
||||
<View className='flex-1 items-center justify-center p-8'>
|
||||
<Text className='text-xl text-white'>
|
||||
{t("companion_login.authorizing")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (screenState === "confirm") {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
className='flex-1 bg-black'
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
>
|
||||
<Text className='mb-2 text-center text-2xl font-bold text-white'>
|
||||
{t("companion_login.login_as", { username })}
|
||||
</Text>
|
||||
|
||||
<Text className='mb-8 text-center text-base text-gray-400'>
|
||||
{t("companion_login.on_server", {
|
||||
server: serverUrl.replace(/^https?:\/\//, ""),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<View className='mb-6 items-center'>
|
||||
<Text className='mb-1 text-sm text-gray-400'>
|
||||
{t("companion_login.pairing_code_label")}
|
||||
</Text>
|
||||
|
||||
<Text className='mb-8 text-center text-4xl font-bold tracking-[6px] text-white'>
|
||||
{pairingCode}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='mb-5'>
|
||||
<Text className='mb-2 text-sm text-gray-400'>
|
||||
{t("login.password_placeholder")}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder={t("login.password_placeholder")}
|
||||
placeholderTextColor='#6B7280'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
returnKeyType='done'
|
||||
onSubmitEditing={handleSendCredentials}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='mt-2'>
|
||||
<Button
|
||||
onPress={handleSendCredentials}
|
||||
disabled={!password.trim()}
|
||||
color='purple'
|
||||
textClassName='flex-1 text-center'
|
||||
>
|
||||
{t("companion_login.authorize_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className='mt-6 items-center'>
|
||||
<TouchableOpacity onPress={handleUseDifferentUser} className='py-2'>
|
||||
<Text className='text-base text-gray-400 underline'>
|
||||
{t("companion_login.use_different_user")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={handleScanAgain} className='py-2'>
|
||||
<Text className='text-sm text-gray-500 underline'>
|
||||
{t("companion_login.scan_again")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
if (screenState === "form") {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
className='flex-1 bg-black'
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
padding: 14,
|
||||
}}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
>
|
||||
<Text className='mb-2 text-2xl font-bold text-white'>
|
||||
{t("companion_login.pairing_enter_credentials")}
|
||||
</Text>
|
||||
|
||||
<View className='mb-5'>
|
||||
<Text className='mb-2 text-sm text-gray-400'>
|
||||
{t("companion_login.pairing_code_label")}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-center text-2xl font-bold tracking-[6px] text-white'
|
||||
value={pairingCode}
|
||||
onChangeText={setPairingCode}
|
||||
placeholder={t("companion_login.pairing_code_label")}
|
||||
placeholderTextColor='#6B7280'
|
||||
autoCapitalize='characters'
|
||||
autoCorrect={false}
|
||||
returnKeyType='next'
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='mb-5'>
|
||||
<Text className='mb-2 text-sm text-gray-400'>
|
||||
{t("companion_login.server")}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrl}
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
placeholderTextColor='#6B7280'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
keyboardType='url'
|
||||
returnKeyType='next'
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='mb-5'>
|
||||
<Text className='mb-2 text-sm text-gray-400'>
|
||||
{t("login.username_placeholder")}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
placeholder={t("login.username_placeholder")}
|
||||
placeholderTextColor='#6B7280'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
returnKeyType='next'
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='mb-5'>
|
||||
<Text className='mb-2 text-sm text-gray-400'>
|
||||
{t("login.password_placeholder")}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder={t("login.password_placeholder")}
|
||||
placeholderTextColor='#6B7280'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
returnKeyType='done'
|
||||
onSubmitEditing={handleSendCredentials}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex-row justify-center gap-3'>
|
||||
<Button
|
||||
onPress={handleScanAgain}
|
||||
color='black'
|
||||
className='w-40 border border-neutral-700 bg-neutral-800'
|
||||
textClassName='flex-1 text-center'
|
||||
>
|
||||
{t("companion_login.scan_again")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={handleSendCredentials}
|
||||
disabled={
|
||||
!serverUrl.trim() ||
|
||||
!username.trim() ||
|
||||
!password.trim() ||
|
||||
!pairingCode.trim()
|
||||
}
|
||||
className='w-40'
|
||||
color='purple'
|
||||
textClassName='flex-1 text-center'
|
||||
>
|
||||
{t("companion_login.authorize_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='flex-1 bg-black items-center justify-center'>
|
||||
{/* Camera full screen */}
|
||||
<CameraView
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
onBarcodeScanned={handleBarCodeScanned}
|
||||
barcodeScannerSettings={{
|
||||
barcodeTypes: ["qr"],
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dark overlay */}
|
||||
<View className='absolute inset-0 bg-black/60' />
|
||||
|
||||
{/* Center scan area */}
|
||||
<View className='items-center'>
|
||||
<View className='h-[250px] w-[250px] rounded-2xl border-2 border-white/80' />
|
||||
|
||||
<Text className='mt-6 text-center text-base text-white'>
|
||||
{t("companion_login.align_qr")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleEnterCodeManually}
|
||||
className='mt-4 px-5 py-2'
|
||||
>
|
||||
<Text className='text-sm text-gray-400 underline'>
|
||||
{t("companion_login.enter_code_manually")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,15 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { t } from "i18next";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
BackHandler,
|
||||
Easing,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -9,6 +17,7 @@ import { TVInput } from "./TVInput";
|
||||
|
||||
interface TVAddServerFormProps {
|
||||
onConnect: (url: string) => Promise<void>;
|
||||
onStartPairing?: () => void;
|
||||
onBack: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -78,6 +87,7 @@ const TVBackButton: React.FC<{
|
||||
|
||||
export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
onConnect,
|
||||
onStartPairing,
|
||||
onBack,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
@@ -93,6 +103,24 @@ 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 handleBackPress = () => {
|
||||
if (disabled) return false;
|
||||
onBack();
|
||||
return true;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
handleBackPress,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [onBack, disabled]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
@@ -156,6 +184,18 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
|
||||
{/* Pair with Phone */}
|
||||
{onStartPairing && (
|
||||
<View style={{ marginTop: 32 }}>
|
||||
<Button
|
||||
onPress={onStartPairing}
|
||||
className='bg-neutral-800 border border-neutral-700'
|
||||
>
|
||||
{t("pairing.pair_with_phone")}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { t } from "i18next";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
BackHandler,
|
||||
Easing,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -108,6 +116,24 @@ 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 handleBackPress = () => {
|
||||
if (disabled) return false;
|
||||
onBack();
|
||||
return true;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
handleBackPress,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [onBack, disabled]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
|
||||
@@ -2,28 +2,40 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
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 { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
generatePairingCode,
|
||||
type PairingCredentials,
|
||||
startPairingListener,
|
||||
} from "@/utils/pairingService";
|
||||
import {
|
||||
type AccountSecurityType,
|
||||
getPreviousServers,
|
||||
hashPIN,
|
||||
removeServerFromList,
|
||||
type SavedServer,
|
||||
type SavedServerAccount,
|
||||
saveAccountCredential,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { TVAddServerForm } from "./TVAddServerForm";
|
||||
import { TVAddUserForm } from "./TVAddUserForm";
|
||||
import { TVPasswordEntryModal } from "./TVPasswordEntryModal";
|
||||
import { TVPINEntryModal } from "./TVPINEntryModal";
|
||||
import { TVQRCodeDisplay } from "./TVQRCodeDisplay";
|
||||
import { TVSaveAccountModal } from "./TVSaveAccountModal";
|
||||
import { TVServerSelectionScreen } from "./TVServerSelectionScreen";
|
||||
import { TVUserSelectionScreen } from "./TVUserSelectionScreen";
|
||||
|
||||
type TVLoginScreen =
|
||||
| "server-selection"
|
||||
| "qr-code-display"
|
||||
| "loading"
|
||||
| "user-selection"
|
||||
| "add-server"
|
||||
| "add-user";
|
||||
@@ -91,6 +103,17 @@ export const TVLogin: React.FC = () => {
|
||||
const isAnyModalOpen =
|
||||
showSaveModal || pinModalVisible || passwordModalVisible;
|
||||
|
||||
// Pairing state (companion login via phone)
|
||||
const [showPairingQR, setShowPairingQR] = useState(false);
|
||||
const [pairingCode, setPairingCode] = useState("");
|
||||
const [pendingPairingCredentials, setPendingPairingCredentials] = useState<{
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
// Ref to prevent double-handling when onSave and onClose both fire
|
||||
const pairingHandledRef = useRef(false);
|
||||
|
||||
// Refresh servers list helper
|
||||
const refreshServers = () => {
|
||||
const servers = getPreviousServers();
|
||||
@@ -119,6 +142,7 @@ export const TVLogin: React.FC = () => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopQuickConnectPolling();
|
||||
setShowPairingQR(false);
|
||||
};
|
||||
}, [stopQuickConnectPolling]);
|
||||
|
||||
@@ -262,6 +286,7 @@ export const TVLogin: React.FC = () => {
|
||||
|
||||
switch (account.securityType) {
|
||||
case "none":
|
||||
setCurrentScreen("loading");
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginWithSavedCredential(currentServer.address, account.userId);
|
||||
@@ -281,7 +306,7 @@ export const TVLogin: React.FC = () => {
|
||||
[
|
||||
{
|
||||
text: t("common.ok"),
|
||||
onPress: () => setCurrentScreen("add-user"),
|
||||
onPress: () => setCurrentScreen("user-selection"),
|
||||
},
|
||||
],
|
||||
);
|
||||
@@ -306,6 +331,7 @@ export const TVLogin: React.FC = () => {
|
||||
const handlePinSuccess = async () => {
|
||||
setPinModalVisible(false);
|
||||
if (currentServer && selectedAccount) {
|
||||
setCurrentScreen("loading");
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginWithSavedCredential(
|
||||
@@ -323,6 +349,12 @@ export const TVLogin: React.FC = () => {
|
||||
? t("server.session_expired")
|
||||
: t("login.connection_failed"),
|
||||
isSessionExpired ? t("server.please_login_again") : errorMessage,
|
||||
[
|
||||
{
|
||||
text: t("common.ok"),
|
||||
onPress: () => setCurrentScreen("user-selection"),
|
||||
},
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -334,6 +366,7 @@ export const TVLogin: React.FC = () => {
|
||||
// Handle password submit
|
||||
const handlePasswordSubmit = async (password: string) => {
|
||||
if (currentServer && selectedAccount) {
|
||||
setCurrentScreen("loading");
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginWithPassword(
|
||||
@@ -345,6 +378,12 @@ export const TVLogin: React.FC = () => {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.invalid_username_or_password"),
|
||||
[
|
||||
{
|
||||
text: t("common.ok"),
|
||||
onPress: () => setCurrentScreen("user-selection"),
|
||||
},
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -408,7 +447,63 @@ export const TVLogin: React.FC = () => {
|
||||
pinCode?: string,
|
||||
) => {
|
||||
setShowSaveModal(false);
|
||||
const pairingCreds = pendingPairingCredentials;
|
||||
|
||||
if (pairingCreds) {
|
||||
// Pairing flow: mark as handled, login, then save credential
|
||||
pairingHandledRef.current = true;
|
||||
setPendingPairingCredentials(null);
|
||||
setPendingLogin(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginWithPassword(
|
||||
pairingCreds.serverUrl,
|
||||
pairingCreds.username,
|
||||
pairingCreds.password,
|
||||
);
|
||||
// Save credential after successful login
|
||||
try {
|
||||
const token = storage.getString("token");
|
||||
const userJson = storage.getString("user");
|
||||
const storedServerUrl = storage.getString("serverUrl");
|
||||
if (token && userJson && storedServerUrl) {
|
||||
const user = JSON.parse(userJson);
|
||||
let pinHash: string | undefined;
|
||||
if (securityType === "pin" && pinCode) {
|
||||
pinHash = await hashPIN(pinCode);
|
||||
}
|
||||
await saveAccountCredential({
|
||||
serverUrl: storedServerUrl,
|
||||
serverName: storedServerUrl,
|
||||
token,
|
||||
userId: user.Id || "",
|
||||
username: pairingCreds.username,
|
||||
savedAt: Date.now(),
|
||||
securityType,
|
||||
pinHash,
|
||||
primaryImageTag: user.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch (saveError) {
|
||||
console.error(
|
||||
"[TVLogin] Failed to save pairing credential:",
|
||||
saveError,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal login flow
|
||||
if (pendingLogin && currentServer) {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -452,11 +547,77 @@ export const TVLogin: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to QR screen with a fresh code and active listener
|
||||
const goToQRScreen = useCallback(() => {
|
||||
const code = generatePairingCode();
|
||||
setPairingCode(code);
|
||||
setShowPairingQR(true);
|
||||
setCurrentScreen("qr-code-display");
|
||||
}, []);
|
||||
|
||||
// Handle pairing with companion phone
|
||||
const handleStartPairing = useCallback(() => {
|
||||
goToQRScreen();
|
||||
}, [goToQRScreen]);
|
||||
|
||||
// Handle credentials received from companion
|
||||
const handlePairingCredentials = useCallback(
|
||||
(credentials: PairingCredentials) => {
|
||||
setShowPairingQR(false);
|
||||
setCurrentScreen("loading");
|
||||
|
||||
// Store credentials and show save modal (same UX as normal login)
|
||||
setPendingPairingCredentials({
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
setPendingLogin({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
setShowSaveModal(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Listen for pairing credentials when QR is shown
|
||||
useEffect(() => {
|
||||
if (!showPairingQR || !pairingCode) return;
|
||||
|
||||
const cleanup = startPairingListener(
|
||||
pairingCode,
|
||||
handlePairingCredentials,
|
||||
(error) => {
|
||||
console.error("[TVLogin] Pairing error:", error);
|
||||
setShowPairingQR(false);
|
||||
Alert.alert(t("login.error_title"), t("companion_login.error_generic"));
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-dismiss after 5 minutes
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
setShowPairingQR(false);
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [showPairingQR, pairingCode, handlePairingCredentials]);
|
||||
|
||||
// Render current screen
|
||||
const renderScreen = () => {
|
||||
// If API is connected but we're on server/user selection,
|
||||
// it means we need to show add-user form
|
||||
if (api?.basePath && currentScreen !== "add-user") {
|
||||
if (
|
||||
api?.basePath &&
|
||||
currentScreen !== "add-user" &&
|
||||
currentScreen !== "loading"
|
||||
) {
|
||||
// API is ready, show add-user form
|
||||
return (
|
||||
<TVAddUserForm
|
||||
@@ -505,12 +666,55 @@ export const TVLogin: React.FC = () => {
|
||||
return (
|
||||
<TVAddServerForm
|
||||
onConnect={handleConnect}
|
||||
onStartPairing={handleStartPairing}
|
||||
onBack={() => setCurrentScreen("server-selection")}
|
||||
loading={loadingServerCheck}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
case "qr-code-display":
|
||||
return (
|
||||
<TVQRCodeDisplay
|
||||
code={pairingCode}
|
||||
onBack={() => {
|
||||
setShowPairingQR(false);
|
||||
setCurrentScreen("add-server");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "loading":
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#000000",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("pairing.logging_in")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "#9CA3AF",
|
||||
}}
|
||||
>
|
||||
{t("pairing.logging_in_description")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
case "add-user":
|
||||
return (
|
||||
<TVAddUserForm
|
||||
@@ -540,7 +744,31 @@ export const TVLogin: React.FC = () => {
|
||||
<TVSaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
// If onSave already handled this, just clean up
|
||||
if (pairingHandledRef.current) {
|
||||
pairingHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
setShowSaveModal(false);
|
||||
if (pendingPairingCredentials) {
|
||||
// Pairing: user dismissed without saving, login anyway
|
||||
const creds = pendingPairingCredentials;
|
||||
setPendingPairingCredentials(null);
|
||||
setPendingLogin(null);
|
||||
loginWithPassword(
|
||||
creds.serverUrl,
|
||||
creds.username,
|
||||
creds.password,
|
||||
).catch((error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
|
||||
201
components/login/TVQRCodeDisplay.tsx
Normal file
201
components/login/TVQRCodeDisplay.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
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 QRCode from "react-native-qrcode-svg";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
interface TVQRCodeDisplayProps {
|
||||
code: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export const TVQRCodeDisplay: React.FC<TVQRCodeDisplayProps> = ({
|
||||
code,
|
||||
onBack,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const handledRef = useRef(false);
|
||||
|
||||
const qrSize = scaleSize(280);
|
||||
const cardPadding = scaleSize(16);
|
||||
const sectionPadding = scaleSize(32);
|
||||
const outerPadding = scaleSize(60);
|
||||
|
||||
const qrData = JSON.stringify({
|
||||
action: "streamyfin-pair",
|
||||
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();
|
||||
}, [onBack]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
paddingHorizontal: outerPadding,
|
||||
}}
|
||||
>
|
||||
{/* Back Button */}
|
||||
{onBack && <TVBackButton onPress={onBack} />}
|
||||
|
||||
{/* QR Code */}
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
paddingVertical: sectionPadding,
|
||||
paddingHorizontal: cardPadding,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("pairing.waiting_for_phone")}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
padding: cardPadding,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
<QRCode
|
||||
value={qrData}
|
||||
size={qrSize}
|
||||
color='#000000'
|
||||
backgroundColor='#FFFFFF'
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
letterSpacing: scaleSize(8),
|
||||
marginTop: scaleSize(16),
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: scaleSize(8),
|
||||
}}
|
||||
>
|
||||
{t("pairing.scan_with_phone")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user