refactor: login page

This commit is contained in:
Fredrik Burmester
2026-01-31 10:52:21 +01:00
parent 6e85c8d54a
commit 85a74a9a6a
27 changed files with 2422 additions and 1236 deletions

View File

@@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
{/* Password Input */}
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
<Text className='text-neutral-400 text-sm mb-2'>
{t("login.password")}
{t("login.password_placeholder")}
</Text>
<BottomSheetTextInput
value={password}
@@ -136,7 +136,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
setPassword(text);
setError(null);
}}
placeholder={t("login.password")}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
secureTextEntry
autoFocus
@@ -174,7 +174,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
{isLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("login.login")
t("common.login")
)}
</Button>
</View>

View File

@@ -0,0 +1,82 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVAddIconProps {
label: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVAddIcon = React.forwardRef<View, TVAddIconProps>(
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: 160,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View
style={{
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.05)",
marginBottom: 14,
borderWidth: 2,
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
borderStyle: "dashed",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='add'
size={56}
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
/>
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,162 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVInput } from "./TVInput";
interface TVAddServerFormProps {
onConnect: (url: string) => Promise<void>;
onBack: () => void;
loading?: boolean;
disabled?: boolean;
}
const TVBackButton: React.FC<{
onPress: () => void;
label: string;
disabled?: boolean;
}> = ({ onPress, label, disabled = false }) => {
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.05 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={onPress}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
style={{ alignSelf: "flex-start", marginBottom: 24 }}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
}}
>
<Ionicons
name='chevron-back'
size={28}
color={isFocused ? "#000" : "#fff"}
/>
<Text
style={{
color: isFocused ? "#000" : "#fff",
fontSize: 20,
marginLeft: 4,
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
onConnect,
onBack,
loading = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const [serverURL, setServerURL] = useState("");
const handleConnect = async () => {
if (serverURL.trim()) {
await onConnect(serverURL.trim());
}
};
const isDisabled = disabled || loading;
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: 60,
}}
>
{/* Back Button */}
<TVBackButton
onPress={onBack}
label={t("common.back")}
disabled={isDisabled}
/>
{/* Server URL Input */}
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("server.server_url_placeholder")}
value={serverURL}
onChangeText={setServerURL}
keyboardType='url'
autoCapitalize='none'
textContentType='URL'
returnKeyType='done'
hasTVPreferredFocus
disabled={isDisabled}
/>
</View>
{/* Connect Button */}
<View style={{ marginBottom: 24 }}>
<Button
onPress={handleConnect}
loading={loading}
disabled={loading || !serverURL.trim()}
color='white'
>
{t("server.connect_button")}
</Button>
</View>
{/* Hint text */}
<Text
style={{
fontSize: typography.callout,
color: "#6B7280",
textAlign: "left",
paddingHorizontal: 8,
}}
>
{t("server.enter_url_to_jellyfin_server")}
</Text>
</View>
</ScrollView>
);
};

View File

@@ -0,0 +1,230 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVInput } from "./TVInput";
import { TVSaveAccountToggle } from "./TVSaveAccountToggle";
interface TVAddUserFormProps {
serverName: string;
serverAddress: string;
onLogin: (
username: string,
password: string,
saveAccount: boolean,
) => Promise<void>;
onQuickConnect: () => Promise<void>;
onBack: () => void;
loading?: boolean;
disabled?: boolean;
}
const TVBackButton: React.FC<{
onPress: () => void;
label: string;
disabled?: boolean;
}> = ({ onPress, label, disabled = false }) => {
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.05 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={onPress}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
style={{ alignSelf: "flex-start", marginBottom: 40 }}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
}}
>
<Ionicons
name='chevron-back'
size={28}
color={isFocused ? "#000" : "#fff"}
/>
<Text
style={{
color: isFocused ? "#000" : "#fff",
fontSize: 20,
marginLeft: 4,
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
serverName,
serverAddress,
onLogin,
onQuickConnect,
onBack,
loading = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const [credentials, setCredentials] = useState({
username: "",
password: "",
});
const [saveAccount, setSaveAccount] = useState(false);
const handleLogin = async () => {
if (credentials.username.trim()) {
await onLogin(credentials.username, credentials.password, saveAccount);
}
};
const isDisabled = disabled || loading;
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: 60,
}}
>
{/* Back Button */}
<TVBackButton
onPress={onBack}
label={t("common.back")}
disabled={isDisabled}
/>
{/* Title */}
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text style={{ color: "#fff" }}>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginBottom: 40,
}}
>
{serverAddress}
</Text>
{/* Username Input */}
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.username_placeholder")}
value={credentials.username}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
returnKeyType='next'
hasTVPreferredFocus
disabled={isDisabled}
/>
</View>
{/* Password Input */}
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.password_placeholder")}
value={credentials.password}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
secureTextEntry
autoCapitalize='none'
textContentType='password'
returnKeyType='done'
disabled={isDisabled}
/>
</View>
{/* Save Account Toggle */}
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
<TVSaveAccountToggle
value={saveAccount}
onValueChange={setSaveAccount}
label={t("save_account.save_for_later")}
disabled={isDisabled}
/>
</View>
{/* Login Button */}
<View style={{ marginBottom: 16 }}>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim() || loading}
color='white'
>
{t("login.login_button")}
</Button>
</View>
{/* Quick Connect Button */}
<Button
onPress={onQuickConnect}
color='black'
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</ScrollView>
);
};

View File

@@ -0,0 +1,82 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVBackIconProps {
label: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVBackIcon = React.forwardRef<View, TVBackIconProps>(
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: 160,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View
style={{
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.05)",
marginBottom: 14,
borderWidth: 2,
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
borderStyle: "dashed",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='arrow-back'
size={56}
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
/>
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -1,107 +1,37 @@
import { Ionicons } 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, useRef, useState } from "react";
import {
Alert,
Animated,
Easing,
Pressable,
ScrollView,
View,
} from "react-native";
import { z } from "zod";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TVInput } from "@/components/login/TVInput";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
import { TVPreviousServersList } from "@/components/login/TVPreviousServersList";
import { TVSaveAccountModal } from "@/components/login/TVSaveAccountModal";
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
import { useTVServerActionModal } from "@/hooks/useTVServerActionModal";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer";
import {
type AccountSecurityType,
getPreviousServers,
removeServerFromList,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { TVAddServerForm } from "./TVAddServerForm";
import { TVAddUserForm } from "./TVAddUserForm";
import { TVPasswordEntryModal } from "./TVPasswordEntryModal";
import { TVPINEntryModal } from "./TVPINEntryModal";
import { TVSaveAccountModal } from "./TVSaveAccountModal";
import { TVServerSelectionScreen } from "./TVServerSelectionScreen";
import { TVUserSelectionScreen } from "./TVUserSelectionScreen";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
});
const TVBackButton: React.FC<{
onPress: () => void;
label: string;
disabled?: boolean;
}> = ({ onPress, label, disabled = false }) => {
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.05 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={onPress}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
style={{ alignSelf: "flex-start", marginBottom: 40 }}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
}}
>
<Ionicons
name='chevron-back'
size={28}
color={isFocused ? "#000" : "#fff"}
/>
<Text
style={{
color: isFocused ? "#000" : "#fff",
fontSize: 20,
marginLeft: 4,
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
type TVLoginScreen =
| "server-selection"
| "user-selection"
| "add-server"
| "add-user";
export const TVLogin: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const { showServerActionModal } = useTVServerActionModal();
const {
setServer,
login,
@@ -117,20 +47,33 @@ export const TVLogin: React.FC = () => {
password: _password,
} = params as { apiUrl: string; username: string; password: string };
// Selected server persistence
const [selectedTVServer, setSelectedTVServer] = useAtom(selectedTVServerAtom);
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
// Get current servers list
const previousServers = useMemo(() => {
try {
return JSON.parse(_previousServers || "[]") as SavedServer[];
} catch {
return [];
}
}, [_previousServers]);
// Current screen state
const [currentScreen, setCurrentScreen] =
useState<TVLoginScreen>("server-selection");
// Current selected server for user selection screen
const [currentServer, setCurrentServer] = useState<SavedServer | null>(null);
const [serverName, setServerName] = useState<string>("");
// Loading states
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;
@@ -140,20 +83,37 @@ export const TVLogin: React.FC = () => {
// PIN/Password entry for saved accounts
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Server login trigger state
const [loginTriggerServer, setLoginTriggerServer] =
useState<SavedServer | null>(null);
// Track if any modal is open to disable background focus
const isAnyModalOpen =
showSaveModal || pinModalVisible || passwordModalVisible;
// Refresh servers list helper
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
};
// Initialize on mount - check if we have a persisted server
useEffect(() => {
if (selectedTVServer) {
// Find the full server data from previousServers
const server = previousServers.find(
(s) => s.address === selectedTVServer.address,
);
if (server) {
setCurrentServer(server);
setServerName(selectedTVServer.name || "");
setCurrentScreen("user-selection");
} else {
// Server no longer exists, clear persistence
setSelectedTVServer(null);
}
}
}, []);
// Auto login from URL params
useEffect(() => {
(async () => {
@@ -161,7 +121,6 @@ export const TVLogin: React.FC = () => {
await setServer({ address: _apiUrl });
setTimeout(() => {
if (_username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, 0);
@@ -177,169 +136,7 @@ export const TVLogin: React.FC = () => {
});
}, [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);
}
};
// Server action sheet handler
const handleServerAction = (server: SavedServer) => {
showServerActionModal({
server,
onLogin: () => {
// Trigger the login flow in TVPreviousServersList
setLoginTriggerServer(server);
// Reset the trigger after a tick to allow re-triggering the same server
setTimeout(() => setLoginTriggerServer(null), 0);
},
onDelete: () => {
Alert.alert(
t("server.remove_server"),
t("server.remove_server_description", {
server: server.name || server.address,
}),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("common.delete"),
style: "destructive",
onPress: async () => {
await removeServerFromList(server.address);
},
},
],
);
},
});
};
// Server URL checking
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
@@ -387,27 +184,214 @@ export const TVLogin: React.FC = () => {
return undefined;
}
const handleConnect = useCallback(async (url: string) => {
url = url.trim().replace(/\/$/, "");
console.log("[TVLogin] handleConnect called with:", url);
try {
const result = await checkUrl(url);
console.log("[TVLogin] checkUrl result:", result);
if (result === undefined) {
// Handle connecting to a new server
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 });
// Update server list and get the new server data
refreshServers();
// Find or create server entry
const servers = getPreviousServers();
const server = servers.find((s) => s.address === result);
if (server) {
setCurrentServer(server);
setSelectedTVServer({ address: result, name: serverName });
setCurrentScreen("user-selection");
}
} catch (error) {
console.error("[TVLogin] Error in handleConnect:", error);
}
},
[checkUrl, setServer, serverName, setSelectedTVServer],
);
// Handle selecting an existing server
const handleServerSelect = (server: SavedServer) => {
setCurrentServer(server);
setServerName(server.name || "");
setSelectedTVServer({ address: server.address, name: server.name });
setCurrentScreen("user-selection");
};
// Handle changing server (back from user selection)
const handleChangeServer = () => {
setSelectedTVServer(null);
setCurrentServer(null);
setServerName("");
removeServer();
setCurrentScreen("server-selection");
};
// Handle deleting a server
const handleDeleteServer = async (server: SavedServer) => {
await removeServerFromList(server.address);
refreshServers();
// If we deleted the currently selected server, clear it
if (selectedTVServer?.address === server.address) {
setSelectedTVServer(null);
setCurrentServer(null);
}
};
// Handle user selection
const handleUserSelect = async (account: SavedServerAccount) => {
if (!currentServer) return;
switch (account.securityType) {
case "none":
setLoading(true);
try {
await loginWithSavedCredential(currentServer.address, account.userId);
} catch {
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("add-user"),
},
],
);
} finally {
setLoading(false);
}
break;
case "pin":
setSelectedAccount(account);
setPinModalVisible(true);
break;
case "password":
setSelectedAccount(account);
setPasswordModalVisible(true);
break;
}
};
// Handle PIN success
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (currentServer && selectedAccount) {
setLoading(true);
try {
await loginWithSavedCredential(
currentServer.address,
selectedAccount.userId,
);
} catch {
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
);
} finally {
setLoading(false);
}
}
setSelectedAccount(null);
};
// Handle password submit
const handlePasswordSubmit = async (password: string) => {
if (currentServer && selectedAccount) {
setLoading(true);
try {
await loginWithPassword(
currentServer.address,
selectedAccount.username,
password,
);
} catch {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server"),
t("login.invalid_username_or_password"),
);
return;
} finally {
setLoading(false);
}
console.log("[TVLogin] Calling setServer with:", result);
await setServer({ address: result });
console.log("[TVLogin] setServer completed successfully");
} catch (error) {
console.error("[TVLogin] Error in handleConnect:", error);
}
}, []);
setPasswordModalVisible(false);
setSelectedAccount(null);
};
// Handle forgot PIN
const handleForgotPIN = async () => {
setSelectedAccount(null);
setPinModalVisible(false);
};
// Handle login with credentials (from add user form)
const handleLogin = async (
username: string,
password: string,
saveAccount: boolean,
) => {
if (!currentServer) return;
if (saveAccount) {
setPendingLogin({ username, password });
setShowSaveModal(true);
} else {
await performLogin(username, 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,
});
}
};
// Handle quick connect
const handleQuickConnect = async () => {
try {
const code = await initiateQuickConnect();
@@ -426,227 +410,89 @@ export const TVLogin: React.FC = () => {
}
};
// Debug logging
console.log("[TVLogin] Render - api?.basePath:", api?.basePath);
// Render current screen
const renderScreen = () => {
// If API is connected but we're on server/user selection,
// it means we need to show add-user form
if (api?.basePath && currentScreen !== "add-user") {
// API is ready, show add-user form
return (
<TVAddUserForm
serverName={serverName}
serverAddress={api.basePath}
onLogin={handleLogin}
onQuickConnect={handleQuickConnect}
onBack={handleChangeServer}
loading={loading}
disabled={isAnyModalOpen}
/>
);
}
switch (currentScreen) {
case "server-selection":
return (
<TVServerSelectionScreen
onServerSelect={handleServerSelect}
onAddServer={() => setCurrentScreen("add-server")}
onDeleteServer={handleDeleteServer}
disabled={isAnyModalOpen}
/>
);
case "user-selection":
if (!currentServer) {
setCurrentScreen("server-selection");
return null;
}
return (
<TVUserSelectionScreen
server={currentServer}
onUserSelect={handleUserSelect}
onAddUser={() => {
// Set the server in JellyfinProvider and go to add-user
setServer({ address: currentServer.address });
setCurrentScreen("add-user");
}}
onChangeServer={handleChangeServer}
disabled={isAnyModalOpen || loading}
/>
);
case "add-server":
return (
<TVAddServerForm
onConnect={handleConnect}
onBack={() => setCurrentScreen("server-selection")}
loading={loadingServerCheck}
disabled={isAnyModalOpen}
/>
);
case "add-user":
return (
<TVAddUserForm
serverName={serverName}
serverAddress={currentServer?.address || api?.basePath || ""}
onLogin={handleLogin}
onQuickConnect={handleQuickConnect}
onBack={() => {
removeServer();
setCurrentScreen("user-selection");
}}
loading={loading}
disabled={isAnyModalOpen}
/>
);
default:
return null;
}
};
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
{api?.basePath ? (
// ==================== CREDENTIALS SCREEN ====================
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: 60,
}}
>
{/* Back Button */}
<TVBackButton
onPress={() => removeServer()}
label={t("login.change_server")}
disabled={isAnyModalOpen}
/>
{/* Title */}
<Text
style={{
fontSize: 48,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text style={{ color: "#fff" }}>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginBottom: 40,
}}
>
{api.basePath}
</Text>
{/* Username Input - extra padding for focus scale */}
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.username_placeholder")}
value={credentials.username}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
returnKeyType='next'
hasTVPreferredFocus
disabled={isAnyModalOpen}
/>
</View>
{/* Password Input */}
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.password_placeholder")}
value={credentials.password}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
secureTextEntry
autoCapitalize='none'
textContentType='password'
returnKeyType='done'
disabled={isAnyModalOpen}
/>
</View>
{/* Save Account Toggle */}
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
<TVSaveAccountToggle
value={saveAccount}
onValueChange={setSaveAccount}
label={t("save_account.save_for_later")}
disabled={isAnyModalOpen}
/>
</View>
{/* Login Button */}
<View style={{ marginBottom: 16 }}>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim() || loading}
color='white'
>
{t("login.login_button")}
</Button>
</View>
{/* Quick Connect Button */}
<Button
onPress={handleQuickConnect}
color='black'
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</ScrollView>
) : (
// ==================== SERVER SELECTION SCREEN ====================
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: 60,
}}
>
{/* Logo */}
<View style={{ alignItems: "center", marginBottom: 16 }}>
<Image
source={require("@/assets/images/icon-ios-plain.png")}
style={{ width: 150, height: 150 }}
contentFit='contain'
/>
</View>
{/* Title */}
<Text
style={{
fontSize: 48,
fontWeight: "bold",
color: "#FFFFFF",
textAlign: "center",
marginBottom: 8,
}}
>
Streamyfin
</Text>
<Text
style={{
fontSize: 20,
color: "#9CA3AF",
textAlign: "center",
marginBottom: 40,
}}
>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Server URL Input - extra padding for focus scale */}
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("server.server_url_placeholder")}
value={serverURL}
onChangeText={setServerURL}
keyboardType='url'
autoCapitalize='none'
textContentType='URL'
returnKeyType='done'
hasTVPreferredFocus
disabled={isAnyModalOpen}
/>
</View>
{/* Connect Button */}
<View style={{ marginBottom: 24 }}>
<Button
onPress={() => handleConnect(serverURL)}
loading={loadingServerCheck}
disabled={loadingServerCheck || !serverURL.trim()}
color='white'
>
{t("server.connect_button")}
</Button>
</View>
{/* Previous Servers */}
<View style={{ paddingHorizontal: 8 }}>
<TVPreviousServersList
onServerSelect={(s) => handleConnect(s.address)}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
onPinRequired={handlePinRequired}
onPasswordRequired={handlePasswordRequired}
onServerAction={handleServerAction}
loginServerOverride={loginTriggerServer}
disabled={isAnyModalOpen}
/>
</View>
</View>
</ScrollView>
)}
</View>
<View style={{ flex: 1 }}>{renderScreen()}</View>
{/* Save Account Modal */}
<TVSaveAccountModal
@@ -656,7 +502,7 @@ export const TVLogin: React.FC = () => {
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
username={pendingLogin?.username || ""}
/>
{/* PIN Entry Modal */}
@@ -665,11 +511,10 @@ export const TVLogin: React.FC = () => {
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={handleForgotPIN}
serverUrl={selectedServer?.address || ""}
serverUrl={currentServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
@@ -680,7 +525,6 @@ export const TVLogin: React.FC = () => {
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}

View File

@@ -1,3 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -11,8 +12,6 @@ import {
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
import { useTVFocusAnimation } from "@/components/tv";
import { verifyAccountPIN } from "@/utils/secureCredentials";
interface TVPINEntryModalProps {
@@ -25,40 +24,122 @@ interface TVPINEntryModalProps {
username: string;
}
// Forgot PIN Button
const TVForgotPINButton: React.FC<{
// Number pad button
const NumberPadButton: React.FC<{
value: string;
onPress: () => void;
label: string;
hasTVPreferredFocus?: boolean;
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
isBackspace?: boolean;
disabled?: boolean;
}> = ({ value, onPress, hasTVPreferredFocus, isBackspace, disabled }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 100,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
onFocus={() => {
setFocused(true);
animateTo(1.1);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
styles.numberButton,
{
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "transparent",
transform: [{ scale }],
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
},
]}
>
{isBackspace ? (
<Ionicons
name='backspace-outline'
size={28}
color={focused ? "#000" : "#fff"}
/>
) : (
<Text
style={[styles.numberText, { color: focused ? "#000" : "#fff" }]}
>
{value}
</Text>
)}
</Animated.View>
</Pressable>
);
};
// PIN dot indicator
const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({
filled,
error,
}) => (
<View
style={[
styles.pinDot,
filled && styles.pinDotFilled,
error && styles.pinDotError,
]}
/>
);
// Forgot PIN link
const ForgotPINLink: React.FC<{
onPress: () => void;
label: string;
}> = ({ onPress, label }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 100,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<Animated.View
style={{
transform: [{ scale }],
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: focused ? "rgba(255,255,255,0.15)" : "transparent",
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#fff" : "rgba(255,255,255,0.6)",
fontWeight: "500",
color: focused ? "#fff" : "rgba(255,255,255,0.5)",
}}
>
{label}
@@ -80,23 +161,21 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const [pinCode, setPinCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const pinInputRef = useRef<TVPinInputRef>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
const contentScale = useRef(new Animated.Value(0.9)).current;
const shakeAnimation = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
// Reset state when opening
setPinCode("");
setError(null);
setError(false);
setIsVerifying(false);
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
contentScale.setValue(0.9);
Animated.parallel([
Animated.timing(overlayOpacity, {
@@ -105,32 +184,19 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
Animated.timing(contentScale, {
toValue: 1,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
useEffect(() => {
if (visible && isReady) {
const timer = setTimeout(() => {
pinInputRef.current?.focus();
}, 150);
return () => clearTimeout(timer);
}
}, [visible, isReady]);
}, [visible, overlayOpacity, contentScale]);
const shake = () => {
Animated.sequence([
@@ -157,33 +223,42 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
]).start();
};
const handlePinChange = async (value: string) => {
setPinCode(value);
setError(null);
const handleNumberPress = async (num: string) => {
if (isVerifying || pinCode.length >= 4) return;
setError(false);
const newPin = pinCode + num;
setPinCode(newPin);
// Auto-verify when 4 digits entered
if (value.length === 4) {
if (newPin.length === 4) {
setIsVerifying(true);
try {
const isValid = await verifyAccountPIN(serverUrl, userId, value);
const isValid = await verifyAccountPIN(serverUrl, userId, newPin);
if (isValid) {
onSuccess();
setPinCode("");
} else {
setError(t("pin.invalid_pin"));
setError(true);
shake();
setPinCode("");
setTimeout(() => setPinCode(""), 300);
}
} catch {
setError(t("pin.invalid_pin"));
setError(true);
shake();
setPinCode("");
setTimeout(() => setPinCode(""), 300);
} finally {
setIsVerifying(false);
}
}
};
const handleBackspace = () => {
if (isVerifying) return;
setError(false);
setPinCode((prev) => prev.slice(0, -1));
};
const handleForgotPIN = () => {
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
{ text: t("common.cancel"), style: "cancel" },
@@ -204,11 +279,11 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
styles.contentContainer,
{ transform: [{ scale: contentScale }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<BlurView intensity={60} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
@@ -218,44 +293,103 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
style={styles.content}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
<Text style={styles.subtitle}>
{t("pin.enter_pin_for", { username })}
</Text>
</View>
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
<Text style={styles.subtitle}>{username}</Text>
{/* PIN Input */}
{/* PIN Dots */}
<Animated.View
style={[
styles.pinDotsContainer,
{ transform: [{ translateX: shakeAnimation }] },
]}
>
{[0, 1, 2, 3].map((i) => (
<PinDot key={i} filled={pinCode.length > i} error={error} />
))}
</Animated.View>
{/* Number Pad */}
{isReady && (
<Animated.View
style={[
styles.pinContainer,
{ transform: [{ translateX: shakeAnimation }] },
]}
>
<TVPinInput
ref={pinInputRef}
value={pinCode}
onChangeText={handlePinChange}
length={4}
autoFocus
/>
{error && <Text style={styles.errorText}>{error}</Text>}
{isVerifying && (
<Text style={styles.verifyingText}>
{t("common.verifying")}
</Text>
)}
</Animated.View>
<View style={styles.numberPad}>
{/* Row 1: 1-3 */}
<View style={styles.numberRow}>
<NumberPadButton
value='1'
onPress={() => handleNumberPress("1")}
hasTVPreferredFocus
disabled={isVerifying}
/>
<NumberPadButton
value='2'
onPress={() => handleNumberPress("2")}
disabled={isVerifying}
/>
<NumberPadButton
value='3'
onPress={() => handleNumberPress("3")}
disabled={isVerifying}
/>
</View>
{/* Row 2: 4-6 */}
<View style={styles.numberRow}>
<NumberPadButton
value='4'
onPress={() => handleNumberPress("4")}
disabled={isVerifying}
/>
<NumberPadButton
value='5'
onPress={() => handleNumberPress("5")}
disabled={isVerifying}
/>
<NumberPadButton
value='6'
onPress={() => handleNumberPress("6")}
disabled={isVerifying}
/>
</View>
{/* Row 3: 7-9 */}
<View style={styles.numberRow}>
<NumberPadButton
value='7'
onPress={() => handleNumberPress("7")}
disabled={isVerifying}
/>
<NumberPadButton
value='8'
onPress={() => handleNumberPress("8")}
disabled={isVerifying}
/>
<NumberPadButton
value='9'
onPress={() => handleNumberPress("9")}
disabled={isVerifying}
/>
</View>
{/* Row 4: empty, 0, backspace */}
<View style={styles.numberRow}>
<View style={styles.numberButtonPlaceholder} />
<NumberPadButton
value='0'
onPress={() => handleNumberPress("0")}
disabled={isVerifying}
/>
<NumberPadButton
value=''
onPress={handleBackspace}
isBackspace
disabled={isVerifying || pinCode.length === 0}
/>
</View>
</View>
)}
{/* Forgot PIN */}
{isReady && onForgotPIN && (
<View style={styles.forgotContainer}>
<TVForgotPINButton
<ForgotPINLink
onPress={handleForgotPIN}
label={t("pin.forgot_pin")}
hasTVPreferredFocus
/>
</View>
)}
@@ -273,55 +407,81 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
backgroundColor: "rgba(0, 0, 0, 0.8)",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
},
sheetContainer: {
contentContainer: {
width: "100%",
maxWidth: 400,
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
header: {
paddingHorizontal: 48,
marginBottom: 24,
padding: 40,
alignItems: "center",
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
marginBottom: 4,
marginBottom: 8,
textAlign: "center",
},
subtitle: {
fontSize: 16,
fontSize: 18,
color: "rgba(255,255,255,0.6)",
marginBottom: 32,
textAlign: "center",
},
pinContainer: {
paddingHorizontal: 48,
pinDotsContainer: {
flexDirection: "row",
gap: 16,
marginBottom: 32,
},
pinDot: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: "rgba(255,255,255,0.4)",
backgroundColor: "transparent",
},
pinDotFilled: {
backgroundColor: "#fff",
borderColor: "#fff",
},
pinDotError: {
borderColor: "#ef4444",
backgroundColor: "#ef4444",
},
numberPad: {
gap: 12,
marginBottom: 24,
},
numberRow: {
flexDirection: "row",
gap: 12,
},
numberButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
marginBottom: 16,
},
errorText: {
color: "#ef4444",
fontSize: 14,
marginTop: 16,
textAlign: "center",
numberButtonPlaceholder: {
width: 72,
height: 72,
},
verifyingText: {
color: "rgba(255,255,255,0.6)",
fontSize: 14,
marginTop: 16,
textAlign: "center",
numberText: {
fontSize: 28,
fontWeight: "600",
},
forgotContainer: {
alignItems: "center",
marginTop: 8,
},
});

View File

@@ -249,14 +249,16 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
{/* Password Input */}
{isReady && (
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>{t("login.password")}</Text>
<Text style={styles.inputLabel}>
{t("login.password_placeholder")}
</Text>
<TVPasswordInput
value={password}
onChangeText={(text) => {
setPassword(text);
setError(null);
}}
placeholder={t("login.password")}
placeholder={t("login.password_placeholder")}
onSubmitEditing={handleSubmit}
hasTVPreferredFocus
/>

View File

@@ -1,237 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVAccountSelectModal } from "@/hooks/useTVAccountSelectModal";
import {
deleteAccountCredential,
getPreviousServers,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { TVServerCard } from "./TVServerCard";
interface TVPreviousServersListProps {
onServerSelect: (server: SavedServer) => void;
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
onPasswordLogin?: (
serverUrl: string,
username: string,
password: string,
) => Promise<void>;
onAddAccount?: (server: SavedServer) => void;
onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void;
onPasswordRequired?: (
server: SavedServer,
account: SavedServerAccount,
) => void;
// Called when server is pressed to show action sheet (handled by parent)
onServerAction?: (server: SavedServer) => void;
// Called by parent when "Login" is selected from action sheet
loginServerOverride?: SavedServer | null;
// Disable all focusable elements (when a modal is open)
disabled?: boolean;
}
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
onServerSelect,
onQuickLogin,
onAddAccount,
onPinRequired,
onPasswordRequired,
onServerAction,
loginServerOverride,
disabled = false,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { showAccountSelectModal } = useTVAccountSelectModal();
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState<string | null>(null);
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]);
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
};
const handleAccountLogin = async (
server: SavedServer,
account: SavedServerAccount,
) => {
switch (account.securityType) {
case "none":
if (onQuickLogin) {
setLoadingServer(server.address);
try {
await onQuickLogin(server.address, account.userId);
} catch {
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
);
} finally {
setLoadingServer(null);
}
}
break;
case "pin":
if (onPinRequired) {
onPinRequired(server, account);
}
break;
case "password":
if (onPasswordRequired) {
onPasswordRequired(server, account);
}
break;
}
};
const handleDeleteAccount = async (
server: SavedServer,
account: SavedServerAccount,
) => {
Alert.alert(
t("server.remove_saved_login"),
t("server.remove_account_description", { username: account.username }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.remove"),
style: "destructive",
onPress: async () => {
await deleteAccountCredential(server.address, account.userId);
refreshServers();
},
},
],
);
};
const showAccountSelection = (server: SavedServer) => {
showAccountSelectModal({
server,
onAccountSelect: (account) => handleAccountLogin(server, account),
onAddAccount: () => {
if (onAddAccount) {
onAddAccount(server);
}
},
onDeleteAccount: (account) => handleDeleteAccount(server, account),
});
};
// When parent triggers login via loginServerOverride, execute the login flow
useEffect(() => {
if (loginServerOverride) {
const accountCount = loginServerOverride.accounts?.length || 0;
if (accountCount === 0) {
onServerSelect(loginServerOverride);
} else if (accountCount === 1) {
handleAccountLogin(
loginServerOverride,
loginServerOverride.accounts[0],
);
} else {
showAccountSelection(loginServerOverride);
}
}
}, [loginServerOverride]);
const handleServerPress = (server: SavedServer) => {
if (loadingServer) return;
// If onServerAction is provided, delegate to parent for action sheet handling
if (onServerAction) {
onServerAction(server);
return;
}
// Fallback: direct login flow (for backwards compatibility)
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) {
onServerSelect(server);
} else if (accountCount === 1) {
handleAccountLogin(server, server.accounts[0]);
} else {
showAccountSelection(server);
}
};
const getServerSubtitle = (server: SavedServer): string | undefined => {
const accountCount = server.accounts?.length || 0;
if (accountCount > 1) {
return t("server.accounts_count", { count: accountCount });
}
if (accountCount === 1) {
return `${server.accounts[0].username}${t("server.saved")}`;
}
return server.name ? server.address : undefined;
};
const getSecurityIcon = (
server: SavedServer,
): keyof typeof Ionicons.glyphMap | null => {
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) return null;
if (accountCount > 1) {
return "people";
}
const account = server.accounts[0];
switch (account.securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
if (!previousServers.length) return null;
return (
<View style={{ marginTop: 32 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
}}
>
{t("server.previous_servers")}
</Text>
<View style={{ gap: 12 }}>
{previousServers.map((server) => (
<TVServerCard
key={server.address}
title={server.name || server.address}
subtitle={getServerSubtitle(server)}
securityIcon={getSecurityIcon(server)}
isLoading={loadingServer === server.address}
onPress={() => handleServerPress(server)}
disabled={disabled}
/>
))}
</View>
</View>
);
};

View File

@@ -1,152 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useRef, useState } from "react";
import {
ActivityIndicator,
Animated,
Easing,
Pressable,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
interface TVServerCardProps {
title: string;
subtitle?: string;
securityIcon?: keyof typeof Ionicons.glyphMap | null;
isLoading?: boolean;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVServerCard: React.FC<TVServerCardProps> = ({
title,
subtitle,
securityIcon,
isLoading,
onPress,
hasTVPreferredFocus,
disabled = false,
}) => {
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const glowOpacity = useRef(new Animated.Value(0)).current;
const animateFocus = (focused: boolean) => {
Animated.parallel([
Animated.timing(scale, {
toValue: focused ? 1.02 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(glowOpacity, {
toValue: focused ? 0.7 : 0,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
};
const handleFocus = () => {
setIsFocused(true);
animateFocus(true);
};
const handleBlur = () => {
setIsFocused(false);
animateFocus(false);
};
const isDisabled = disabled || isLoading;
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isDisabled}
focusable={!isDisabled}
hasTVPreferredFocus={hasTVPreferredFocus && !isDisabled}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
}}
numberOfLines={1}
>
{title}
</Text>
{subtitle && (
<Text
style={{
fontSize: 16,
color: "#9CA3AF",
marginTop: 4,
}}
numberOfLines={1}
>
{subtitle}
</Text>
)}
</View>
<View style={{ marginLeft: 16 }}>
{isLoading ? (
<ActivityIndicator size='small' color='#fff' />
) : securityIcon ? (
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Ionicons
name={securityIcon}
size={20}
color='#fff'
style={{ marginRight: 8 }}
/>
<Ionicons
name='chevron-forward'
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
</View>
) : (
<Ionicons
name='chevron-forward'
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
)}
</View>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,118 @@
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVServerIconProps {
name: string;
address: string;
onPress: () => void;
onLongPress?: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVServerIcon = React.forwardRef<View, TVServerIconProps>(
(
{
name,
address,
onPress,
onLongPress,
hasTVPreferredFocus,
disabled = false,
},
ref,
) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
// Get the first letter of the server name (or address if no name)
const displayName = name || address;
const initial = displayName.charAt(0).toUpperCase();
return (
<Pressable
ref={ref}
onPress={onPress}
onLongPress={onLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: 160,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View
style={{
width: 140,
height: 140,
borderRadius: 70,
overflow: "hidden",
backgroundColor: focused
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.1)",
marginBottom: 14,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
justifyContent: "center",
alignItems: "center",
}}
>
<Text
style={{
fontSize: 48,
fontWeight: "bold",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
}}
>
{initial}
</Text>
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
marginBottom: 4,
}}
numberOfLines={2}
>
{displayName}
</Text>
{name && (
<Text
style={{
fontSize: typography.callout,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
}}
numberOfLines={1}
>
{address.replace(/^https?:\/\//, "")}
</Text>
)}
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,137 @@
import { Image } from "expo-image";
import { t } from "i18next";
import React, { useMemo } from "react";
import { Alert, ScrollView, View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import type { SavedServer } from "@/utils/secureCredentials";
import { TVAddIcon } from "./TVAddIcon";
import { TVServerIcon } from "./TVServerIcon";
interface TVServerSelectionScreenProps {
onServerSelect: (server: SavedServer) => void;
onAddServer: () => void;
onDeleteServer: (server: SavedServer) => void;
disabled?: boolean;
}
export const TVServerSelectionScreen: React.FC<
TVServerSelectionScreenProps
> = ({ onServerSelect, onAddServer, onDeleteServer, disabled = false }) => {
const typography = useScaledTVTypography();
const [_previousServers] = useMMKVString("previousServers");
const previousServers = useMemo(() => {
try {
return JSON.parse(_previousServers || "[]") as SavedServer[];
} catch {
return [];
}
}, [_previousServers]);
const hasServers = previousServers.length > 0;
const handleDeleteServer = (server: SavedServer) => {
Alert.alert(
t("server.remove_server"),
t("server.remove_server_description", {
server: server.name || server.address,
}),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.delete"),
style: "destructive",
onPress: () => onDeleteServer(server),
},
],
);
};
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
alignItems: "center",
paddingHorizontal: 60,
}}
>
{/* Logo */}
<View style={{ alignItems: "center", marginBottom: 16 }}>
<Image
source={require("@/assets/images/icon-ios-plain.png")}
style={{ width: 150, height: 150 }}
contentFit='contain'
/>
</View>
{/* Title */}
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
textAlign: "center",
marginBottom: 8,
}}
>
Streamyfin
</Text>
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
textAlign: "center",
marginBottom: 48,
}}
>
{hasServers
? t("server.select_your_server")
: t("server.add_server_to_get_started")}
</Text>
{/* Server Icons Grid */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 20,
gap: 24,
}}
style={{ overflow: "visible" }}
>
{previousServers.map((server, index) => (
<TVServerIcon
key={server.address}
name={server.name || ""}
address={server.address}
onPress={() => onServerSelect(server)}
onLongPress={() => handleDeleteServer(server)}
hasTVPreferredFocus={index === 0}
disabled={disabled}
/>
))}
{/* Add Server Button */}
<TVAddIcon
label={t("server.add_server")}
onPress={onAddServer}
hasTVPreferredFocus={!hasServers}
disabled={disabled}
/>
</ScrollView>
</View>
</ScrollView>
);
};

View File

@@ -0,0 +1,127 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import type { AccountSecurityType } from "@/utils/secureCredentials";
export interface TVUserIconProps {
username: string;
securityType: AccountSecurityType;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVUserIcon = React.forwardRef<View, TVUserIconProps>(
(
{ username, securityType, onPress, hasTVPreferredFocus, disabled = false },
ref,
) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
switch (securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
const hasSecurityProtection = securityType !== "none";
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: 160,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View style={{ position: "relative" }}>
<View
style={{
width: 140,
height: 140,
borderRadius: 70,
overflow: "hidden",
backgroundColor: focused
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.1)",
marginBottom: 14,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='person'
size={56}
color={
focused ? "rgba(255,255,255,0.6)" : "rgba(255,255,255,0.4)"
}
/>
</View>
{/* Security badge */}
{hasSecurityProtection && (
<View
style={{
position: "absolute",
bottom: 10,
right: 10,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.9)",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
}}
>
<Ionicons name={getSecurityIcon()} size={16} color='#000' />
</View>
)}
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
}}
numberOfLines={2}
>
{username}
</Text>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,130 @@
import { t } from "i18next";
import React from "react";
import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import type {
SavedServer,
SavedServerAccount,
} from "@/utils/secureCredentials";
import { TVAddIcon } from "./TVAddIcon";
import { TVBackIcon } from "./TVBackIcon";
import { TVUserIcon } from "./TVUserIcon";
interface TVUserSelectionScreenProps {
server: SavedServer;
onUserSelect: (account: SavedServerAccount) => void;
onAddUser: () => void;
onChangeServer: () => void;
disabled?: boolean;
}
export const TVUserSelectionScreen: React.FC<TVUserSelectionScreenProps> = ({
server,
onUserSelect,
onAddUser,
onChangeServer,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const accounts = server.accounts || [];
const hasAccounts = accounts.length > 0;
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
alignItems: "center",
paddingHorizontal: 60,
}}
>
{/* Server Info Header */}
<View style={{ marginBottom: 48, alignItems: "center" }}>
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
textAlign: "center",
marginBottom: 8,
}}
>
{server.name || server.address}
</Text>
{server.name && (
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
textAlign: "center",
}}
>
{server.address.replace(/^https?:\/\//, "")}
</Text>
)}
<Text
style={{
fontSize: typography.body,
color: "#6B7280",
textAlign: "center",
marginTop: 16,
}}
>
{hasAccounts
? t("login.select_user")
: t("login.add_user_to_login")}
</Text>
</View>
{/* User Icons Grid with Back and Add buttons */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 20,
gap: 24,
}}
style={{ overflow: "visible" }}
>
{/* Back/Change Server Button (left) */}
<TVBackIcon
label={t("server.change_server")}
onPress={onChangeServer}
disabled={disabled}
/>
{/* User Icons */}
{accounts.map((account, index) => (
<TVUserIcon
key={account.userId}
username={account.username}
securityType={account.securityType}
onPress={() => onUserSelect(account)}
hasTVPreferredFocus={index === 0}
disabled={disabled}
/>
))}
{/* Add User Button (right) */}
<TVAddIcon
label={t("login.add_user")}
onPress={onAddUser}
hasTVPreferredFocus={!hasAccounts}
disabled={disabled}
/>
</ScrollView>
</View>
</ScrollView>
);
};