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; } 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( Platform.isTV ? "form" : "scanning", ); const [pairingCode, setPairingCode] = useState(""); const [serverUrl, setServerUrl] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [errorMessage, setErrorMessage] = useState(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(() => { if (!ExpoCamera) return; ExpoCamera.Camera.getCameraPermissionsAsync().then((response) => { if (!response.granted) { ExpoCamera.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 ( {t("companion_login.error_permission_denied")} {Platform.OS === "ios" && ( Linking.openSettings()} className='mt-4 rounded-lg bg-purple-600 px-6 py-3' > {t("companion_login.open_settings")} )} ); } if (screenState === "success") { return ( {t("companion_login.success_title")} {t("companion_login.pairing_tv_connecting")} ); } if (screenState === "error") { return ( {t("companion_login.error_title")} {errorMessage} ); } if (screenState === "sending") { return ( {t("companion_login.authorizing")} ); } if (screenState === "confirm") { return ( {t("companion_login.login_as", { username })} {t("companion_login.on_server", { server: serverUrl.replace(/^https?:\/\//, ""), })} {t("companion_login.pairing_code_label")} {pairingCode} {t("login.password_placeholder")} {t("companion_login.use_different_user")} {t("companion_login.scan_again")} ); } if (screenState === "form") { return ( {t("companion_login.pairing_enter_credentials")} {t("companion_login.pairing_code_label")} {t("companion_login.server")} {t("login.username_placeholder")} {t("login.password_placeholder")} ); } const CameraView = ExpoCamera?.CameraView; if (!CameraView) { return ( ); } return ( {/* Camera full screen */} {/* Dark overlay */} {/* Center scan area */} {t("companion_login.align_qr")} {t("companion_login.enter_code_manually")} ); };