From c1e12d5898181c00ea9517ac88b3cf0583de7f63 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:30:50 +0100 Subject: [PATCH] fix: login page for tv --- app/login.tsx | 164 +------------ app/login.tv.tsx | 604 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 605 insertions(+), 163 deletions(-) create mode 100644 app/login.tv.tsx diff --git a/app/login.tsx b/app/login.tsx index 33d06d41..20c574b2 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -312,169 +312,7 @@ const Login: React.FC = () => { } }; - 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((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} - extraClassName='mb-4' - autoFocus={false} - blurOnSubmit={true} - /> - - {/* Password */} - - 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} - extraClassName='mb-4' - autoFocus={false} - blurOnSubmit={true} - /> - - - - - - - - - - ) : ( - // ------------ 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); - }} - onQuickLogin={handleQuickLoginWithSavedCredential} - onPasswordLogin={handlePasswordLogin} - onAddAccount={handleAddAccount} - /> - - - - )} - - - ) : ( + return ( // Mobile layout { + 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); + + // PIN/Password entry for saved accounts + const [pinModalVisible, setPinModalVisible] = useState(false); + const [passwordModalVisible, setPasswordModalVisible] = useState(false); + const [selectedServer, setSelectedServer] = useState( + null, + ); + const [selectedAccount, setSelectedAccount] = + useState(null); + + // Server discovery + const { + servers: discoveredServers, + isSearching, + startDiscovery, + } = useJellyfinDiscovery(); + + // Auto login from URL params + useEffect(() => { + (async () => { + if (_apiUrl) { + await setServer({ address: _apiUrl }); + setTimeout(() => { + if (_username && _password) { + setCredentials({ username: _username, password: _password }); + login(_username, _password); + } + }, 0); + } + })(); + }, [_apiUrl, _username, _password]); + + // Update header + useEffect(() => { + navigation.setOptions({ + headerTitle: serverName, + headerShown: false, + }); + }, [serverName, navigation]); + + const handleLogin = async () => { + 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 handlePinRequired = ( + server: SavedServer, + account: SavedServerAccount, + ) => { + setSelectedServer(server); + setSelectedAccount(account); + setPinModalVisible(true); + }; + + const handlePasswordRequired = ( + server: SavedServer, + account: SavedServerAccount, + ) => { + setSelectedServer(server); + setSelectedAccount(account); + setPasswordModalVisible(true); + }; + + const handlePinSuccess = async () => { + setPinModalVisible(false); + if (selectedServer && selectedAccount) { + await handleQuickLoginWithSavedCredential( + selectedServer.address, + selectedAccount.userId, + ); + } + setSelectedServer(null); + setSelectedAccount(null); + }; + + const handlePasswordSubmit = async (password: string) => { + if (selectedServer && selectedAccount) { + await handlePasswordLogin( + selectedServer.address, + selectedAccount.username, + password, + ); + } + setPasswordModalVisible(false); + setSelectedServer(null); + setSelectedAccount(null); + }; + + const handleForgotPIN = async () => { + if (selectedServer) { + setSelectedServer(null); + setSelectedAccount(null); + setPinModalVisible(false); + } + }; + + 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 ? ( + // ==================== CREDENTIALS SCREEN ==================== + + + {/* Back Button */} + removeServer()} + style={{ + flexDirection: "row", + alignItems: "center", + marginBottom: 40, + }} + > + + + {t("login.change_server")} + + + + {/* Title */} + + {serverName ? ( + <> + {`${t("login.login_to_title")} `} + {serverName} + + ) : ( + t("login.login_title") + )} + + + {api.basePath} + + + {/* Username Input */} + + + setCredentials((prev) => ({ ...prev, username: text })) + } + autoCapitalize='none' + autoCorrect={false} + textContentType='username' + returnKeyType='next' + hasTVPreferredFocus + /> + + + {/* Password Input */} + + + setCredentials((prev) => ({ ...prev, password: text })) + } + secureTextEntry + autoCapitalize='none' + textContentType='password' + returnKeyType='done' + /> + + + {/* Save Account Toggle */} + + + + + {/* Login Button */} + + + + + {/* Quick Connect Button */} + + + + ) : ( + // ==================== SERVER SELECTION SCREEN ==================== + + + {/* Logo */} + + + + + {/* Title */} + + Streamyfin + + + {t("server.enter_url_to_jellyfin_server")} + + + {/* Server URL Input */} + + + + + {/* Connect Button */} + + + + + {/* Server Discovery */} + + + + + {/* Discovered Servers */} + {discoveredServers.length > 0 && ( + + + {t("server.servers")} + + {discoveredServers.map((server) => ( + { + setServerURL(server.address); + if (server.serverName) { + setServerName(server.serverName); + } + handleConnect(server.address); + }} + /> + ))} + + )} + + {/* Previous Servers */} + handleConnect(s.address)} + onQuickLogin={handleQuickLoginWithSavedCredential} + onPasswordLogin={handlePasswordLogin} + onAddAccount={handleAddAccount} + onPinRequired={handlePinRequired} + onPasswordRequired={handlePasswordRequired} + /> + + + )} + + + {/* Save Account Modal */} + { + setShowSaveModal(false); + setPendingLogin(null); + }} + onSave={handleSaveAccountConfirm} + username={pendingLogin?.username || credentials.username} + /> + + {/* PIN Entry Modal */} + { + setPinModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSuccess={handlePinSuccess} + onForgotPIN={handleForgotPIN} + serverUrl={selectedServer?.address || ""} + userId={selectedAccount?.userId || ""} + username={selectedAccount?.username || ""} + /> + + {/* Password Entry Modal */} + { + setPasswordModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSubmit={handlePasswordSubmit} + username={selectedAccount?.username || ""} + /> + + ); +}; + +export default TVLogin;