mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-30 15:08:25 +00:00
feat(tv): migrate login to white design with navigation modals
This commit is contained in:
103
.claude/agents/tv-validator.md
Normal file
103
.claude/agents/tv-validator.md
Normal 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]
|
||||
```
|
||||
@@ -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}
|
||||
|
||||
159
app/tv-account-select-modal.tsx
Normal file
159
app/tv-account-select-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
app/tv-server-action-modal.tsx
Normal file
250
app/tv-server-action-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
]}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
34
hooks/useTVAccountSelectModal.ts
Normal file
34
hooks/useTVAccountSelectModal.ts
Normal 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 };
|
||||
};
|
||||
29
hooks/useTVServerActionModal.ts
Normal file
29
hooks/useTVServerActionModal.ts
Normal 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 };
|
||||
};
|
||||
14
utils/atoms/tvAccountSelectModal.ts
Normal file
14
utils/atoms/tvAccountSelectModal.ts
Normal 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);
|
||||
10
utils/atoms/tvServerActionModal.ts
Normal file
10
utils/atoms/tvServerActionModal.ts
Normal 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);
|
||||
Reference in New Issue
Block a user