feat(tv): migrate login to white design with navigation modals

This commit is contained in:
Fredrik Burmester
2026-01-29 12:12:20 +01:00
parent 80136f1800
commit 2c0a9b6cd9
18 changed files with 757 additions and 438 deletions

View File

@@ -132,7 +132,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<Animated.View
style={{
transform: [{ scale }],
shadowColor: color === "black" ? "#ffffff" : "#a855f7",
shadowColor: "#ffffff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 10 : 0,

View File

@@ -3,7 +3,6 @@ import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import type { SavedServerAccount } from "@/utils/secureCredentials";
interface TVAccountCardProps {
@@ -85,7 +84,7 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
@@ -143,7 +142,7 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
</View>
{/* Security Icon */}
<Ionicons name={getSecurityIcon()} size={24} color={Colors.primary} />
<Ionicons name={getSecurityIcon()} size={24} color='#fff' />
</View>
</Animated.View>
</Pressable>

View File

@@ -58,20 +58,25 @@ export const TVInput: React.FC<TVInputProps> = ({
<Animated.View
style={{
transform: [{ scale }],
borderRadius: 10,
borderWidth: 3,
borderColor: isFocused ? "#FFFFFF" : "#333333",
borderRadius: 12,
backgroundColor: isFocused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.08)",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
}}
>
<TextInput
ref={inputRef}
placeholder={displayPlaceholder}
placeholderTextColor='rgba(255,255,255,0.35)'
allowFontScaling={false}
style={[
{
height: 68,
fontSize: 24,
height: 64,
fontSize: 22,
color: "#FFFFFF",
paddingHorizontal: 20,
},
style,
]}

View File

@@ -19,13 +19,10 @@ 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,
TVServerActionSheet,
} from "@/components/login/TVPreviousServersList";
import { TVPreviousServersList } from "@/components/login/TVPreviousServersList";
import { TVSaveAccountModal } from "@/components/login/TVSaveAccountModal";
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
import { Colors } from "@/constants/Colors";
import { useTVServerActionModal } from "@/hooks/useTVServerActionModal";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import {
type AccountSecurityType,
@@ -78,21 +75,17 @@ const TVBackButton: React.FC<{
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused
? "rgba(168, 85, 247, 0.2)"
: "transparent",
borderWidth: 2,
borderColor: isFocused ? Colors.primary : "transparent",
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
}}
>
<Ionicons
name='chevron-back'
size={28}
color={isFocused ? "#FFFFFF" : Colors.primary}
color={isFocused ? "#000" : "#fff"}
/>
<Text
style={{
color: isFocused ? "#FFFFFF" : Colors.primary,
color: isFocused ? "#000" : "#fff",
fontSize: 20,
marginLeft: 4,
}}
@@ -108,6 +101,7 @@ export const TVLogin: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const { showServerActionModal } = useTVServerActionModal();
const {
setServer,
login,
@@ -152,20 +146,13 @@ export const TVLogin: React.FC = () => {
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Server action sheet state
const [showServerActionSheet, setShowServerActionSheet] = useState(false);
const [actionSheetServer, setActionSheetServer] =
useState<SavedServer | null>(null);
// Server login trigger state
const [loginTriggerServer, setLoginTriggerServer] =
useState<SavedServer | null>(null);
const [actionSheetKey, setActionSheetKey] = useState(0);
// Track if any modal is open to disable background focus
const isAnyModalOpen =
showSaveModal ||
pinModalVisible ||
passwordModalVisible ||
showServerActionSheet;
showSaveModal || pinModalVisible || passwordModalVisible;
// Auto login from URL params
useEffect(() => {
@@ -319,48 +306,38 @@ export const TVLogin: React.FC = () => {
}
};
// Server action sheet handlers
// Server action sheet handler
const handleServerAction = (server: SavedServer) => {
setActionSheetServer(server);
setActionSheetKey((k) => k + 1); // Force remount to reset focus
setShowServerActionSheet(true);
};
const handleServerActionLogin = () => {
setShowServerActionSheet(false);
if (actionSheetServer) {
// Trigger the login flow in TVPreviousServersList
setLoginTriggerServer(actionSheetServer);
// Reset the trigger after a tick to allow re-triggering the same server
setTimeout(() => setLoginTriggerServer(null), 0);
}
};
const handleServerActionDelete = () => {
if (!actionSheetServer) return;
Alert.alert(
t("server.remove_server"),
t("server.remove_server_description", {
server: actionSheetServer.name || actionSheetServer.address,
}),
[
{
text: t("common.cancel"),
style: "cancel",
onPress: () => setShowServerActionSheet(false),
},
{
text: t("common.delete"),
style: "destructive",
onPress: async () => {
await removeServerFromList(actionSheetServer.address);
setShowServerActionSheet(false);
setActionSheetServer(null);
},
},
],
);
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);
},
},
],
);
},
});
};
const checkUrl = useCallback(async (url: string) => {
@@ -493,7 +470,7 @@ export const TVLogin: React.FC = () => {
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text style={{ color: Colors.primary }}>{serverName}</Text>
<Text style={{ color: "#fff" }}>{serverName}</Text>
</>
) : (
t("login.login_title")
@@ -558,6 +535,7 @@ export const TVLogin: React.FC = () => {
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim() || loading}
color='white'
>
{t("login.login_button")}
</Button>
@@ -595,7 +573,7 @@ export const TVLogin: React.FC = () => {
{/* Logo */}
<View style={{ alignItems: "center", marginBottom: 16 }}>
<Image
source={require("@/assets/images/icon-tvos.png")}
source={require("@/assets/images/icon-ios-plain.png")}
style={{ width: 150, height: 150 }}
contentFit='contain'
/>
@@ -645,6 +623,7 @@ export const TVLogin: React.FC = () => {
onPress={() => handleConnect(serverURL)}
loading={loadingServerCheck}
disabled={loadingServerCheck || !serverURL.trim()}
color='white'
>
{t("server.connect_button")}
</Button>
@@ -706,16 +685,6 @@ export const TVLogin: React.FC = () => {
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
{/* Server Action Sheet */}
<TVServerActionSheet
key={actionSheetKey}
visible={showServerActionSheet}
server={actionSheetServer}
onLogin={handleServerActionLogin}
onDelete={handleServerActionDelete}
onClose={() => setShowServerActionSheet(false)}
/>
</View>
);
};

View File

@@ -49,7 +49,7 @@ const TVForgotPINButton: React.FC<{
paddingVertical: 10,
borderRadius: 8,
backgroundColor: focused
? "rgba(168, 85, 247, 0.2)"
? "rgba(255, 255, 255, 0.15)"
: "transparent",
},
]}
@@ -57,7 +57,7 @@ const TVForgotPINButton: React.FC<{
<Text
style={{
fontSize: 16,
color: focused ? "#d8b4fe" : "#a855f7",
color: focused ? "#fff" : "rgba(255,255,255,0.6)",
fontWeight: "500",
}}
>

View File

@@ -47,10 +47,10 @@ const TVSubmitButton: React.FC<{
animatedStyle,
{
backgroundColor: focused
? "#a855f7"
? "#fff"
: isDisabled
? "#4a4a4a"
: "#7c3aed",
: "rgba(255,255,255,0.15)",
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 10,
@@ -64,14 +64,18 @@ const TVSubmitButton: React.FC<{
]}
>
{loading ? (
<ActivityIndicator size='small' color='#fff' />
<ActivityIndicator size='small' color={focused ? "#000" : "#fff"} />
) : (
<>
<Ionicons name='log-in-outline' size={20} color='#fff' />
<Ionicons
name='log-in-outline'
size={20}
color={focused ? "#000" : "#fff"}
/>
<Text
style={{
fontSize: 16,
color: "#fff",
color: focused ? "#000" : "#fff",
fontWeight: "600",
}}
>
@@ -119,7 +123,7 @@ const TVPasswordInput: React.FC<{
backgroundColor: "#1F2937",
borderRadius: 12,
borderWidth: 2,
borderColor: focused ? "#6366F1" : "#374151",
borderColor: focused ? "#fff" : "#374151",
paddingHorizontal: 16,
paddingVertical: 14,
},

View File

@@ -1,210 +1,20 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Animated,
Easing,
Modal,
Pressable,
ScrollView,
View,
} from "react-native";
import { Alert, View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { Button } from "@/components/Button";
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 { TVAccountCard } from "./TVAccountCard";
import { TVServerCard } from "./TVServerCard";
// Action card for server action sheet (Apple TV style)
const TVServerActionCard: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isDestructive = variant === "destructive";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 180,
height: 90,
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
gap: 8,
}}
>
<Ionicons
name={icon}
size={28}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: 16,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
textAlign: "center",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
// Server action sheet component (bottom sheet with horizontal scrolling)
const TVServerActionSheet: React.FC<{
visible: boolean;
server: SavedServer | null;
onLogin: () => void;
onDelete: () => void;
onClose: () => void;
}> = ({ visible, server, onLogin, onDelete, onClose }) => {
const { t } = useTranslation();
if (!server) return null;
return (
<Modal
visible={visible}
transparent
animationType='fade'
onRequestClose={onClose}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 8,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{server.name || server.address}
</Text>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
<TVServerActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={onLogin}
/>
<TVServerActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={onDelete}
/>
<TVServerActionCard
label={t("common.cancel")}
icon='close-outline'
onPress={onClose}
/>
</ScrollView>
</View>
</BlurView>
</View>
</Modal>
);
};
interface TVPreviousServersListProps {
onServerSelect: (server: SavedServer) => void;
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
@@ -227,9 +37,6 @@ interface TVPreviousServersListProps {
disabled?: boolean;
}
// Export the action sheet for use in parent components
export { TVServerActionSheet };
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
onServerSelect,
onQuickLogin,
@@ -241,37 +48,16 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
disabled = false,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { showAccountSelectModal } = useTVAccountSelectModal();
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [showAccountsModal, setShowAccountsModal] = useState(false);
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]);
// 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 {
setSelectedServer(loginServerOverride);
setShowAccountsModal(true);
}
}
}, [loginServerOverride]);
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
@@ -281,8 +67,6 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
server: SavedServer,
account: SavedServerAccount,
) => {
setShowAccountsModal(false);
switch (account.securityType) {
case "none":
if (onQuickLogin) {
@@ -315,6 +99,58 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
}
};
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;
@@ -331,8 +167,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
} else if (accountCount === 1) {
handleAccountLogin(server, server.accounts[0]);
} else {
setSelectedServer(server);
setShowAccountsModal(true);
showAccountSelection(server);
}
};
@@ -369,39 +204,13 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
}
};
const handleDeleteAccount = async (account: SavedServerAccount) => {
if (!selectedServer) return;
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(
selectedServer.address,
account.userId,
);
refreshServers();
if (selectedServer.accounts.length <= 1) {
setShowAccountsModal(false);
}
},
},
],
);
};
if (!previousServers.length) return null;
return (
<View style={{ marginTop: 32 }}>
<Text
style={{
fontSize: 24,
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
@@ -423,90 +232,6 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
/>
))}
</View>
{/* TV Account Selection Modal */}
<Modal
visible={showAccountsModal}
transparent
animationType='fade'
onRequestClose={() => setShowAccountsModal(false)}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.9)",
justifyContent: "center",
alignItems: "center",
padding: 80,
}}
>
<View
style={{
backgroundColor: "#1a1a1a",
borderRadius: 24,
padding: 40,
width: "100%",
maxWidth: 700,
}}
>
<Text
style={{
fontSize: 32,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("server.select_account")}
</Text>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginBottom: 32,
}}
>
{selectedServer?.name || selectedServer?.address}
</Text>
<View style={{ gap: 12, marginBottom: 24 }}>
{selectedServer?.accounts.map((account, index) => (
<TVAccountCard
key={account.userId}
account={account}
onPress={() =>
selectedServer &&
handleAccountLogin(selectedServer, account)
}
onLongPress={() => handleDeleteAccount(account)}
hasTVPreferredFocus={index === 0}
/>
))}
</View>
<View style={{ gap: 12 }}>
<Button
onPress={() => {
setShowAccountsModal(false);
if (selectedServer && onAddAccount) {
onAddAccount(selectedServer);
}
}}
color='purple'
>
{t("server.add_account")}
</Button>
<Button
onPress={() => setShowAccountsModal(false)}
color='black'
className='bg-neutral-800'
>
{t("common.cancel")}
</Button>
</View>
</View>
</View>
</Modal>
</View>
);
};

View File

@@ -75,10 +75,10 @@ const TVSaveButton: React.FC<{
animatedStyle,
{
backgroundColor: focused
? "#a855f7"
? "#fff"
: disabled
? "#4a4a4a"
: "#7c3aed",
: "rgba(255,255,255,0.15)",
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 10,
@@ -89,11 +89,15 @@ const TVSaveButton: React.FC<{
},
]}
>
<Ionicons name='checkmark' size={20} color='#fff' />
<Ionicons
name='checkmark'
size={20}
color={focused ? "#000" : "#fff"}
/>
<Text
style={{
fontSize: 16,
color: "#fff",
color: focused ? "#000" : "#fff",
fontWeight: "600",
}}
>

View File

@@ -1,7 +1,6 @@
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
interface TVSaveAccountToggleProps {
value: boolean;
@@ -62,7 +61,7 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
@@ -97,7 +96,7 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
width: 60,
height: 34,
borderRadius: 17,
backgroundColor: value ? Colors.primary : "#3f3f46",
backgroundColor: value ? "#fff" : "#3f3f46",
justifyContent: "center",
paddingHorizontal: 3,
}}
@@ -107,7 +106,7 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: "white",
backgroundColor: value ? "#000" : "#fff",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>

View File

@@ -8,7 +8,6 @@ import {
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
interface TVServerCardProps {
title: string;
@@ -75,7 +74,7 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
@@ -123,13 +122,13 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
<View style={{ marginLeft: 16 }}>
{isLoading ? (
<ActivityIndicator size='small' color={Colors.primary} />
<ActivityIndicator size='small' color='#fff' />
) : securityIcon ? (
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Ionicons
name={securityIcon}
size={20}
color={Colors.primary}
color='#fff'
style={{ marginRight: 8 }}
/>
<Ionicons