mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-22 19:18:10 +00:00
fix(tv): login form
This commit is contained in:
500
app/login.tsx
500
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<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
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 ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
className='flex flex-row items-center pr-2 pl-1'
|
||||
>
|
||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||
<Text className=' ml-1 text-purple-600'>
|
||||
{t("login.change_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : 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<string | undefined>} 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 <TVLogin />;
|
||||
}
|
||||
/**
|
||||
* 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<void>}
|
||||
*
|
||||
* 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
|
||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<View className='flex flex-col flex-1 justify-center'>
|
||||
<View className='px-4 w-full'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
{serverName ? (
|
||||
<>
|
||||
{`${t("login.login_to_title")} `}
|
||||
<Text className='text-purple-600'>{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
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}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
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}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setSaveAccount(!saveAccount)}
|
||||
className='flex flex-row items-center py-2'
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Switch
|
||||
value={saveAccount}
|
||||
onValueChange={setSaveAccount}
|
||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||
thumbColor='white'
|
||||
/>
|
||||
<Text className='ml-3 text-neutral-300'>
|
||||
{t("save_account.save_for_later")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View className='flex flex-row items-center justify-between'>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={!credentials.username.trim()}
|
||||
className='flex-1 mr-2'
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
onPress={handleQuickConnect}
|
||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name='cellphone-lock'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
/>
|
||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<Input
|
||||
aria-label='Server URL'
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => {
|
||||
await handleConnect(serverURL);
|
||||
}}
|
||||
className='w-full grow'
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
<JellyfinServerDiscovery
|
||||
onServerSelect={async (server) => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) {
|
||||
setServerName(server.serverName);
|
||||
}
|
||||
await handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
<PreviousServersList
|
||||
onServerSelect={async (s) => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Save Account Modal */}
|
||||
<SaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
username={pendingLogin?.username || credentials.username}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
return <Login />;
|
||||
};
|
||||
|
||||
export default Login;
|
||||
export default LoginPage;
|
||||
|
||||
456
components/login/Login.tsx
Normal file
456
components/login/Login.tsx
Normal file
@@ -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<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
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 ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
className='flex flex-row items-center pr-2 pl-1'
|
||||
>
|
||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||
<Text className=' ml-1 text-purple-600'>
|
||||
{t("login.change_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : 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 (
|
||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<View className='flex flex-col flex-1 justify-center'>
|
||||
<View className='px-4 w-full'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
{serverName ? (
|
||||
<>
|
||||
{`${t("login.login_to_title")} `}
|
||||
<Text className='text-purple-600'>{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
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}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
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}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setSaveAccount(!saveAccount)}
|
||||
className='flex flex-row items-center py-2'
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Switch
|
||||
value={saveAccount}
|
||||
onValueChange={setSaveAccount}
|
||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||
thumbColor='white'
|
||||
/>
|
||||
<Text className='ml-3 text-neutral-300'>
|
||||
{t("save_account.save_for_later")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View className='flex flex-row items-center justify-between'>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={!credentials.username.trim()}
|
||||
className='flex-1 mr-2'
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
onPress={handleQuickConnect}
|
||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name='cellphone-lock'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
/>
|
||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<Input
|
||||
aria-label='Server URL'
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => {
|
||||
await handleConnect(serverURL);
|
||||
}}
|
||||
className='w-full grow'
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
<JellyfinServerDiscovery
|
||||
onServerSelect={async (server) => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) {
|
||||
setServerName(server.serverName);
|
||||
}
|
||||
await handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
<PreviousServersList
|
||||
onServerSelect={async (s) => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<SaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
username={pendingLogin?.username || credentials.username}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -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 = () => {
|
||||
<KeyboardAvoidingView behavior='padding' style={{ flex: 1 }}>
|
||||
{api?.basePath ? (
|
||||
// ==================== CREDENTIALS SCREEN ====================
|
||||
<View
|
||||
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={{ width: "100%", maxWidth: 800, paddingHorizontal: 40 }}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Back Button */}
|
||||
<Pressable
|
||||
@@ -377,8 +394,8 @@ const TVLogin: React.FC = () => {
|
||||
{api.basePath}
|
||||
</Text>
|
||||
|
||||
{/* Username Input */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
{/* Username Input - extra padding for focus scale */}
|
||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("login.username_placeholder")}
|
||||
value={credentials.username}
|
||||
@@ -394,7 +411,7 @@ const TVLogin: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("login.password_placeholder")}
|
||||
value={credentials.password}
|
||||
@@ -409,7 +426,7 @@ const TVLogin: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Save Account Toggle */}
|
||||
<View style={{ marginBottom: 32 }}>
|
||||
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
||||
<TVSaveAccountToggle
|
||||
value={saveAccount}
|
||||
onValueChange={setSaveAccount}
|
||||
@@ -437,19 +454,30 @@ const TVLogin: React.FC = () => {
|
||||
{t("login.quick_connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
// ==================== SERVER SELECTION SCREEN ====================
|
||||
<View
|
||||
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={{ width: "100%", maxWidth: 800, paddingHorizontal: 40 }}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<View style={{ alignItems: "center", marginBottom: 16 }}>
|
||||
<Image
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
source={require("@/assets/images/icon-tvos.png")}
|
||||
style={{ width: 150, height: 150 }}
|
||||
contentFit='contain'
|
||||
/>
|
||||
@@ -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")}
|
||||
</Text>
|
||||
|
||||
{/* Server URL Input */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
{/* Server URL Input - extra padding for focus scale */}
|
||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
value={serverURL}
|
||||
@@ -493,7 +521,7 @@ const TVLogin: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Connect Button */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
onPress={() => handleConnect(serverURL)}
|
||||
loading={loadingServerCheck}
|
||||
@@ -504,7 +532,7 @@ const TVLogin: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Server Discovery */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Button
|
||||
onPress={startDiscovery}
|
||||
color='black'
|
||||
@@ -518,45 +546,57 @@ const TVLogin: React.FC = () => {
|
||||
|
||||
{/* Discovered Servers */}
|
||||
{discoveredServers.length > 0 && (
|
||||
<View style={{ marginTop: 16, gap: 12 }}>
|
||||
<View
|
||||
style={{
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("server.servers")}
|
||||
</Text>
|
||||
{discoveredServers.map((server) => (
|
||||
<TVServerCard
|
||||
key={server.address}
|
||||
title={server.serverName || server.address}
|
||||
subtitle={server.serverName ? server.address : undefined}
|
||||
onPress={() => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) {
|
||||
setServerName(server.serverName);
|
||||
<View style={{ gap: 16 }}>
|
||||
{discoveredServers.map((server) => (
|
||||
<TVServerCard
|
||||
key={server.address}
|
||||
title={server.serverName || server.address}
|
||||
subtitle={
|
||||
server.serverName ? server.address : undefined
|
||||
}
|
||||
handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
onPress={() => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) {
|
||||
setServerName(server.serverName);
|
||||
}
|
||||
handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Previous Servers */}
|
||||
<TVPreviousServersList
|
||||
onServerSelect={(s) => handleConnect(s.address)}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
onPinRequired={handlePinRequired}
|
||||
onPasswordRequired={handlePasswordRequired}
|
||||
/>
|
||||
<View style={{ paddingHorizontal: 8 }}>
|
||||
<TVPreviousServersList
|
||||
onServerSelect={(s) => handleConnect(s.address)}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
onPinRequired={handlePinRequired}
|
||||
onPasswordRequired={handlePasswordRequired}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
@@ -600,5 +640,3 @@ const TVLogin: React.FC = () => {
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default TVLogin;
|
||||
Reference in New Issue
Block a user