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 } from "jotai"; import { useCallback, useEffect, useState } from "react"; import { Alert, Keyboard, KeyboardAvoidingView, Platform, 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 { PreviousServersList } from "@/components/PreviousServersList"; import { Colors } from "@/constants/Colors"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; const CredentialsSchema = z.object({ username: z.string().min(1, t("login.username_required")), }); const Login: React.FC = () => { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); const { setServer, login, removeServer, initiateQuickConnect } = useJellyfin(); 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, }); /** * A way to auto login based on a link */ useEffect(() => { (async () => { if (_apiUrl) { await setServer({ address: _apiUrl, }); // Wait for server setup and state updates to complete setTimeout(() => { if (_username && _password) { setCredentials({ username: _username, password: _password }); login(_username, _password); } }, 0); } })(); }, [_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(); setLoading(true); try { const result = CredentialsSchema.safeParse(credentials); if (result.success) { await login(credentials.username, credentials.password); } } 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_occured"), ); } } finally { setLoading(false); } }; /** * Checks the availability and validity of a Jellyfin server URL. * * This function attempts to connect to a Jellyfin server using the provided URL. * It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses. * * @param {string} url - The base URL of the Jellyfin server to check. * @returns {Promise} A Promise that resolves to: * - The full URL (including protocol) if a valid Jellyfin server is found. * - undefined if no valid server is found at the given URL. * * Side effects: * - Sets loadingServerCheck state to true at the beginning and false at the end. * - Logs errors and timeout information to the console. */ 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; } /** * Handles the connection attempt to a Jellyfin server. * * This function trims the input URL, checks its validity using the `checkUrl` function, * and sets the server address if a valid connection is established. * * @param {string} url - The URL of the Jellyfin server to connect to. * * @returns {Promise} * * Side effects: * - Calls `checkUrl` to validate the server URL. * - Shows an alert if the connection fails. * - Sets the server address using `setServer` if the connection is successful. * */ 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) { Alert.alert( t("login.quick_connect"), t("login.enter_code_to_login", { code: code }), [ { text: t("login.got_it"), }, ], ); } } catch (_error) { Alert.alert( t("login.error_title"), t("login.failed_to_initiate_quick_connect"), ); } }; return Platform.isTV ? ( // TV layout {api?.basePath ? ( // ------------ Username/Password view ------------ {/* Safe centered column with max width so TV doesn’t stretch too far */} {serverName ? ( <> {`${t("login.login_to_title")} `} {serverName} ) : ( t("login.login_title") )} {api.basePath} {/* Username */} setCredentials({ ...credentials, username: text }) } value={credentials.username} keyboardType='default' returnKeyType='done' autoCapitalize='none' textContentType='oneTimeCode' clearButtonMode='while-editing' maxLength={500} extraClassName='mb-4' /> {/* Password */} setCredentials({ ...credentials, password: text }) } value={credentials.password} secureTextEntry keyboardType='default' returnKeyType='done' autoCapitalize='none' textContentType='password' clearButtonMode='while-editing' maxLength={500} extraClassName='mb-4' /> ) : ( // ------------ Server connect view ------------ Streamyfin {t("server.enter_url_to_jellyfin_server")} {/* Full-width Input with clear focus ring */} {/* Full-width primary button */} {/* Lists stay full width but inside max width container */} { setServerURL(server.address); if (server.serverName) setServerName(server.serverName); await handleConnect(server.address); }} /> { await handleConnect(s.address); }} /> )} ) : ( // Mobile layout {api?.basePath ? ( {serverName ? ( <> {`${t("login.login_to_title")} `} {serverName} ) : ( t("login.login_title") )} {api.basePath} setCredentials({ ...credentials, username: text }) } value={credentials.username} keyboardType='default' returnKeyType='done' autoCapitalize='none' // Changed from username to oneTimeCode because it is a known issue in RN // https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037 textContentType='oneTimeCode' clearButtonMode='while-editing' maxLength={500} /> setCredentials({ ...credentials, password: text }) } value={credentials.password} secureTextEntry keyboardType='default' returnKeyType='done' autoCapitalize='none' textContentType='password' clearButtonMode='while-editing' maxLength={500} /> ) : ( 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); }} /> )} ); }; export default Login;