import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { t } from "i18next"; import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; import { Alert, Keyboard, KeyboardAvoidingView, Platform, Switch, TouchableOpacity, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { z } from "zod"; import { Button } from "@/components/Button"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal"; import { PreviousServersList } from "@/components/PreviousServersList"; import { Colors } from "@/constants/Colors"; import { apiAtom, pendingAccountSaveAtom, useJellyfin, userAtom, } from "@/providers/JellyfinProvider"; import type { SavedServer } from "@/utils/secureCredentials"; const CredentialsSchema = z.object({ username: z.string().min(1, t("login.username_required")), }); export const Login: React.FC = () => { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); const user = useAtomValue(userAtom); const { setServer, login, removeServer, initiateQuickConnect, stopQuickConnectPolling, loginWithSavedCredential, loginWithPassword, } = useJellyfin(); const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom); const { apiUrl: _apiUrl, username: _username, password: _password, } = params as { apiUrl: string; username: string; password: string }; const [loadingServerCheck, setLoadingServerCheck] = useState(false); const [loading, setLoading] = useState(false); const [serverURL, setServerURL] = useState(_apiUrl || ""); const [serverName, setServerName] = useState(""); const [credentials, setCredentials] = useState<{ username: string; password: string; }>({ username: _username || "", password: _password || "", }); // Quick Connect code shown in the in-app sheet while polling for authorization const [quickConnectCode, setQuickConnectCode] = useState(null); // Close the code sheet as soon as the session is authorized — the native // Alert used before had no programmatic dismiss and stayed open after login. // A Quick Connect login with "save account" on flags the post-login save: // the protection picker shows globally once the session exists (this screen // unmounts on login, so it can't host the modal). useEffect(() => { if (user) { if (quickConnectCode && saveAccount) { setPendingAccountSave({ serverName }); } setQuickConnectCode(null); } }, [user]); // Stop Quick Connect polling when leaving the login page (parity with TVLogin) useEffect(() => { return () => { stopQuickConnectPolling(); }; }, [stopQuickConnectPolling]); // Going back to server selection keeps this component mounted (same screen, // different state), so the unmount cleanup above doesn't run. Without this a // code authorized after leaving would silently log the user in later. useEffect(() => { if (!api?.basePath) { stopQuickConnectPolling(); setQuickConnectCode(null); } }, [api?.basePath, stopQuickConnectPolling]); // Save account state — only the intent lives here; the protection picker is // the global PendingAccountSaveModal, shown after the login succeeds. const [saveAccount, setSaveAccount] = useState(false); // Handle URL params for server connection useEffect(() => { (async () => { if (_apiUrl) { await setServer({ address: _apiUrl, }); } })(); }, [_apiUrl]); // Handle auto-login when api is ready and credentials are provided via URL params useEffect(() => { if (api?.basePath && _apiUrl && _username && _password) { setCredentials({ username: _username, password: _password }); login(_username, _password); } }, [api?.basePath, _apiUrl, _username, _password]); useEffect(() => { navigation.setOptions({ headerTitle: serverName, headerLeft: () => api?.basePath ? ( { removeServer(); }} className='flex flex-row items-center pr-2 pl-1' > {t("login.change_server")} ) : null, }); }, [serverName, navigation, api?.basePath]); const handleLogin = async () => { Keyboard.dismiss(); const result = CredentialsSchema.safeParse(credentials); if (!result.success) return; const ok = await performLogin(credentials.username, credentials.password); // The protection picker shows AFTER a successful login (global modal) — // never for a failed one. if (ok && saveAccount) { setPendingAccountSave({ serverName }); } }; const performLogin = async ( username: string, password: string, ): Promise => { setLoading(true); try { await login(username, password, serverName); return true; } catch (error) { if (error instanceof Error) { Alert.alert(t("login.connection_failed"), error.message); } else { Alert.alert( t("login.connection_failed"), t("login.an_unexpected_error_occurred"), ); } return false; } finally { setLoading(false); } }; const handleQuickLoginWithSavedCredential = async ( serverUrl: string, userId: string, ) => { await loginWithSavedCredential(serverUrl, userId); }; const handlePasswordLogin = async ( serverUrl: string, username: string, password: string, ) => { await loginWithPassword(serverUrl, username, password); }; const handleAddAccount = (server: SavedServer) => { setServer({ address: server.address }); if (server.name) { setServerName(server.name); } }; const checkUrl = useCallback(async (url: string) => { setLoadingServerCheck(true); const baseUrl = url.replace(/^https?:\/\//i, ""); const protocols = ["https", "http"]; try { return checkHttp(baseUrl, protocols); } catch (e) { if (e instanceof Error && e.message === "Server too old") { throw e; } return undefined; } finally { setLoadingServerCheck(false); } }, []); async function checkHttp(baseUrl: string, protocols: string[]) { for (const protocol of protocols) { try { const response = await fetch( `${protocol}://${baseUrl}/System/Info/Public`, { mode: "cors", }, ); if (response.ok) { const data = (await response.json()) as PublicSystemInfo; const serverVersion = data.Version?.split("."); if (serverVersion && +serverVersion[0] <= 10) { if (+serverVersion[1] < 10) { Alert.alert( t("login.too_old_server_text"), t("login.too_old_server_description"), ); throw new Error("Server too old"); } } setServerName(data.ServerName || ""); return `${protocol}://${baseUrl}`; } } catch (e) { if (e instanceof Error && e.message === "Server too old") { throw e; } } } return undefined; } const handleConnect = useCallback(async (url: string) => { url = url.trim().replace(/\/$/, ""); try { const result = await checkUrl(url); if (result === undefined) { Alert.alert( t("login.connection_failed"), t("login.could_not_connect_to_server"), ); return; } await setServer({ address: result }); } catch {} }, []); const handleQuickConnect = async () => { try { const code = await initiateQuickConnect(); if (code) { setQuickConnectCode(code); } } catch (_error) { Alert.alert( t("login.error_title"), t("login.failed_to_initiate_quick_connect"), ); } }; return ( {api?.basePath ? ( {serverName ? ( <> {`${t("login.login_to_title")} `} {serverName} ) : ( t("login.login_title") )} {api.basePath} setCredentials((prev) => ({ ...prev, username: text })) } onEndEditing={(e) => { const newValue = e.nativeEvent.text; if (newValue && newValue !== credentials.username) { setCredentials((prev) => ({ ...prev, username: newValue, })); } }} value={credentials.username} keyboardType='default' returnKeyType='done' autoCapitalize='none' autoCorrect={false} textContentType='username' clearButtonMode='while-editing' maxLength={500} /> setCredentials((prev) => ({ ...prev, password: text })) } onEndEditing={(e) => { const newValue = e.nativeEvent.text; if (newValue && newValue !== credentials.password) { setCredentials((prev) => ({ ...prev, password: newValue, })); } }} value={credentials.password} secureTextEntry keyboardType='default' returnKeyType='done' autoCapitalize='none' textContentType='password' clearButtonMode='while-editing' maxLength={500} /> setSaveAccount(!saveAccount)} className='flex flex-row items-center py-2' activeOpacity={0.7} > {t("save_account.save_for_later")} ) : ( Streamyfin {t("server.enter_url_to_jellyfin_server")} { setServerURL(server.address); if (server.serverName) { setServerName(server.serverName); } await handleConnect(server.address); }} /> { await handleConnect(s.address); }} onQuickLogin={handleQuickLoginWithSavedCredential} onPasswordLogin={handlePasswordLogin} onAddAccount={handleAddAccount} /> )} {/* Dismissing only hides the code — polling continues so the login still completes if the code is authorized from another device afterwards. */} setQuickConnectCode(null)} /> ); };