diff --git a/app/login.tsx b/app/login.tsx index 20c574b2..d028ae4f 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -1,497 +1,13 @@ -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, - 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 { PreviousServersList } from "@/components/PreviousServersList"; -import { SaveAccountModal } from "@/components/SaveAccountModal"; -import { Colors } from "@/constants/Colors"; -import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; -import type { - AccountSecurityType, - SavedServer, -} from "@/utils/secureCredentials"; +import { Platform } from "react-native"; +import { Login } from "@/components/login/Login"; +import { TVLogin } from "@/components/login/TVLogin"; -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, - loginWithSavedCredential, - loginWithPassword, - } = 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 || "", - }); - - // Save account state - const [saveAccount, setSaveAccount] = useState(false); - const [showSaveModal, setShowSaveModal] = useState(false); - const [pendingLogin, setPendingLogin] = useState<{ - username: string; - password: string; - } | null>(null); - - /** - * 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(); - - const result = CredentialsSchema.safeParse(credentials); - if (!result.success) return; - - if (saveAccount) { - // Show save account modal to choose security type - setPendingLogin({ - username: credentials.username, - password: credentials.password, - }); - setShowSaveModal(true); - } else { - // Login without saving - await performLogin(credentials.username, credentials.password); - } - }; - - const performLogin = async ( - username: string, - password: string, - options?: { - saveAccount?: boolean; - securityType?: AccountSecurityType; - pinCode?: string; - }, - ) => { - setLoading(true); - try { - await login(username, password, serverName, options); - } 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); - setPendingLogin(null); - } - }; - - const handleSaveAccountConfirm = async ( - securityType: AccountSecurityType, - pinCode?: string, - ) => { - setShowSaveModal(false); - if (pendingLogin) { - await performLogin(pendingLogin.username, pendingLogin.password, { - saveAccount: true, - securityType, - pinCode, - }); - } - }; - - 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) => { - // Server is already selected, go to credential entry - setServer({ address: server.address }); - if (server.name) { - setServerName(server.name); - } - }; - - /** - * 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; +const LoginPage: React.FC = () => { + if (Platform.isTV) { + return ; } - /** - * 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 ( - // Mobile layout - - - {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} - /> - - - )} - - - {/* Save Account Modal */} - { - setShowSaveModal(false); - setPendingLogin(null); - }} - onSave={handleSaveAccountConfirm} - username={pendingLogin?.username || credentials.username} - /> - - ); + return ; }; -export default Login; +export default LoginPage; diff --git a/components/login/Login.tsx b/components/login/Login.tsx new file mode 100644 index 00000000..0b1fedc7 --- /dev/null +++ b/components/login/Login.tsx @@ -0,0 +1,456 @@ +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, + 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 { PreviousServersList } from "@/components/PreviousServersList"; +import { SaveAccountModal } from "@/components/SaveAccountModal"; +import { Colors } from "@/constants/Colors"; +import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; +import type { + AccountSecurityType, + 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 { + setServer, + login, + removeServer, + initiateQuickConnect, + loginWithSavedCredential, + loginWithPassword, + } = 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 || "", + }); + + // Save account state + const [saveAccount, setSaveAccount] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + const [pendingLogin, setPendingLogin] = useState<{ + username: string; + password: string; + } | null>(null); + + useEffect(() => { + (async () => { + if (_apiUrl) { + await setServer({ + address: _apiUrl, + }); + + 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(); + + const result = CredentialsSchema.safeParse(credentials); + if (!result.success) return; + + if (saveAccount) { + setPendingLogin({ + username: credentials.username, + password: credentials.password, + }); + setShowSaveModal(true); + } else { + await performLogin(credentials.username, credentials.password); + } + }; + + const performLogin = async ( + username: string, + password: string, + options?: { + saveAccount?: boolean; + securityType?: AccountSecurityType; + pinCode?: string; + }, + ) => { + setLoading(true); + try { + await login(username, password, serverName, options); + } 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); + setPendingLogin(null); + } + }; + + const handleSaveAccountConfirm = async ( + securityType: AccountSecurityType, + pinCode?: string, + ) => { + setShowSaveModal(false); + if (pendingLogin) { + await performLogin(pendingLogin.username, pendingLogin.password, { + saveAccount: true, + securityType, + pinCode, + }); + } + }; + + 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) { + 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 ( + + + {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} + /> + + + )} + + + { + setShowSaveModal(false); + setPendingLogin(null); + }} + onSave={handleSaveAccountConfirm} + username={pendingLogin?.username || credentials.username} + /> + + ); +}; diff --git a/app/login.tv.tsx b/components/login/TVLogin.tsx similarity index 84% rename from app/login.tv.tsx rename to components/login/TVLogin.tsx index 498907c0..20b9cb05 100644 --- a/app/login.tv.tsx +++ b/components/login/TVLogin.tsx @@ -5,7 +5,13 @@ import { useLocalSearchParams, useNavigation } from "expo-router"; import { t } from "i18next"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useState } from "react"; -import { Alert, KeyboardAvoidingView, Pressable, View } from "react-native"; +import { + Alert, + KeyboardAvoidingView, + Pressable, + ScrollView, + View, +} from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { z } from "zod"; import { Button } from "@/components/Button"; @@ -30,7 +36,7 @@ const CredentialsSchema = z.object({ username: z.string().min(1, t("login.username_required")), }); -const TVLogin: React.FC = () => { +export const TVLogin: React.FC = () => { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); @@ -322,11 +328,22 @@ const TVLogin: React.FC = () => { {api?.basePath ? ( // ==================== CREDENTIALS SCREEN ==================== - {/* Back Button */} { {api.basePath} - {/* Username Input */} - + {/* Username Input - extra padding for focus scale */} + { {/* Password Input */} - + { {/* Save Account Toggle */} - + { {t("login.quick_connect")} - + ) : ( // ==================== SERVER SELECTION SCREEN ==================== - {/* Logo */} @@ -472,14 +500,14 @@ const TVLogin: React.FC = () => { fontSize: 20, color: "#9CA3AF", textAlign: "center", - marginBottom: 32, + marginBottom: 40, }} > {t("server.enter_url_to_jellyfin_server")} - {/* Server URL Input */} - + {/* Server URL Input - extra padding for focus scale */} + { {/* Connect Button */} - +