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

@@ -0,0 +1,103 @@
---
name: tv-validator
description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements.
tools: Read, Glob, Grep
model: haiku
color: blue
---
You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations.
## Critical Rules to Check
### 1. No .tv.tsx File Suffix
The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it.
**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution
**Correct**: Use `Platform.isTV` conditional rendering in the main file:
```typescript
if (Platform.isTV) {
return <TVMyComponent />;
}
return <MyComponent />;
```
### 2. No FlashList on TV
FlashList has focus issues on TV. Use FlatList instead.
**Violation**: `<FlashList` in TV code paths
**Correct**:
```typescript
{Platform.isTV ? (
<FlatList removeClippedSubviews={false} ... />
) : (
<FlashList ... />
)}
```
### 3. Modal Pattern
Never use overlay/absolute-positioned modals on TV. They break back button handling.
**Violation**: `position: "absolute"` or `Modal` component for TV overlays
**Correct**: Use navigation-based pattern:
- Create Jotai atom for state
- Hook that sets atom and calls `router.push()`
- Page in `app/(auth)/` that reads atom
- `Stack.Screen` with `presentation: "transparentModal"`
### 4. Typography
All TV text must use `TVTypography` component.
**Violation**: Raw `<Text>` in TV components
**Correct**: `<TVTypography variant="title">...</TVTypography>`
### 5. No Purple Accent Colors
TV uses white for focus states, not purple.
**Violation**: Purple/violet colors in TV focused states
**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds
### 6. Focus Handling
- Only ONE element should have `hasTVPreferredFocus={true}`
- Focusable items need `disabled={isModalOpen}` when overlays are visible
- Use `onFocus`/`onBlur` with scale animations
- Add padding for scale animations (focus scale clips without it)
### 7. List Configuration
TV lists need:
- `removeClippedSubviews={false}`
- `overflow: "visible"` on containers
- Sufficient padding for focus scale animations
### 8. Horizontal Padding
Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20).
### 9. Focus Guide Navigation
For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop.
Use `useState` for refs (not `useRef`) to trigger re-renders.
## Review Process
1. Read the file(s) to review
2. Check each rule above
3. Report violations with:
- Line number
- What's wrong
- How to fix it
4. If no violations, confirm the code follows TV patterns
## Output Format
```
## TV Validation Results
### ✓ Passes
- [List of rules that pass]
### ✗ Violations
- **[Rule Name]** (line X): [Description]
Fix: [How to correct it]
### Recommendations
- [Optional suggestions for improvement]
```

View File

@@ -466,6 +466,22 @@ function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name='tv-server-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}

View File

@@ -0,0 +1,159 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, TVFocusGuideView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TVAccountCard } from "@/components/login/TVAccountCard";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
import { store } from "@/utils/store";
export default function TVAccountSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvAccountSelectModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const contentScale = useRef(new Animated.Value(0.9)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
contentScale.setValue(0.9);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(contentScale, {
toValue: 1,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvAccountSelectModalAtom, null);
};
}, [overlayOpacity, contentScale]);
const handleClose = () => {
router.back();
};
if (!modalState) {
return null;
}
return (
<Animated.View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.9)",
justifyContent: "center",
alignItems: "center",
padding: 80,
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
transform: [{ scale: contentScale }],
width: "100%",
maxWidth: 700,
}}
>
<BlurView
intensity={40}
tint='dark'
style={{
borderRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
padding: 40,
}}
>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("server.select_account")}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginBottom: 32,
}}
>
{modalState.server.name || modalState.server.address}
</Text>
{isReady && (
<>
<View style={{ gap: 12, marginBottom: 24 }}>
{modalState.server.accounts?.map((account, index) => (
<TVAccountCard
key={account.userId}
account={account}
onPress={() => {
modalState.onAccountSelect(account);
router.back();
}}
onLongPress={() => {
modalState.onDeleteAccount(account);
}}
hasTVPreferredFocus={index === 0}
/>
))}
</View>
<View style={{ gap: 12 }}>
<Button
onPress={() => {
modalState.onAddAccount();
router.back();
}}
color='white'
>
{t("server.add_account")}
</Button>
<Button
onPress={handleClose}
color='black'
className='bg-neutral-800'
>
{t("common.cancel")}
</Button>
</View>
</>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}

View File

@@ -0,0 +1,250 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
} from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal";
import { store } from "@/utils/store";
// Action card component
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 typography = useScaledTVTypography();
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: typography.callout,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
textAlign: "center",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export default function TVServerActionModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvServerActionModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvServerActionModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
const handleLogin = () => {
modalState?.onLogin();
router.back();
};
const handleDelete = () => {
modalState?.onDelete();
router.back();
};
const handleClose = () => {
router.back();
};
if (!modalState) {
return null;
}
return (
<Animated.View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
width: "100%",
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 8,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{modalState.server.name || modalState.server.address}
</Text>
{/* Horizontal options */}
{isReady && (
<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={handleLogin}
/>
<TVServerActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={handleDelete}
/>
<TVServerActionCard
label={t("common.cancel")}
icon='close-outline'
onPress={handleClose}
/>
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}

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

View File

@@ -0,0 +1,34 @@
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
import type {
SavedServer,
SavedServerAccount,
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
interface ShowAccountSelectModalParams {
server: SavedServer;
onAccountSelect: (account: SavedServerAccount) => void;
onAddAccount: () => void;
onDeleteAccount: (account: SavedServerAccount) => void;
}
export const useTVAccountSelectModal = () => {
const router = useRouter();
const showAccountSelectModal = useCallback(
(params: ShowAccountSelectModalParams) => {
store.set(tvAccountSelectModalAtom, {
server: params.server,
onAccountSelect: params.onAccountSelect,
onAddAccount: params.onAddAccount,
onDeleteAccount: params.onDeleteAccount,
});
router.push("/tv-account-select-modal");
},
[router],
);
return { showAccountSelectModal };
};

View File

@@ -0,0 +1,29 @@
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal";
import type { SavedServer } from "@/utils/secureCredentials";
import { store } from "@/utils/store";
interface ShowServerActionModalParams {
server: SavedServer;
onLogin: () => void;
onDelete: () => void;
}
export const useTVServerActionModal = () => {
const router = useRouter();
const showServerActionModal = useCallback(
(params: ShowServerActionModalParams) => {
store.set(tvServerActionModalAtom, {
server: params.server,
onLogin: params.onLogin,
onDelete: params.onDelete,
});
router.push("/tv-server-action-modal");
},
[router],
);
return { showServerActionModal };
};

View File

@@ -0,0 +1,14 @@
import { atom } from "jotai";
import type {
SavedServer,
SavedServerAccount,
} from "@/utils/secureCredentials";
export type TVAccountSelectModalState = {
server: SavedServer;
onAccountSelect: (account: SavedServerAccount) => void;
onAddAccount: () => void;
onDeleteAccount: (account: SavedServerAccount) => void;
} | null;
export const tvAccountSelectModalAtom = atom<TVAccountSelectModalState>(null);

View File

@@ -0,0 +1,10 @@
import { atom } from "jotai";
import type { SavedServer } from "@/utils/secureCredentials";
export type TVServerActionModalState = {
server: SavedServer;
onLogin: () => void;
onDelete: () => void;
} | null;
export const tvServerActionModalAtom = atom<TVServerActionModalState>(null);