diff --git a/.claude/agents/tv-validator.md b/.claude/agents/tv-validator.md
new file mode 100644
index 00000000..a38dd751
--- /dev/null
+++ b/.claude/agents/tv-validator.md
@@ -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 ;
+}
+return ;
+```
+
+### 2. No FlashList on TV
+FlashList has focus issues on TV. Use FlatList instead.
+
+**Violation**: `
+) : (
+
+)}
+```
+
+### 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 `` in TV components
+**Correct**: `...`
+
+### 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]
+```
diff --git a/app/_layout.tsx b/app/_layout.tsx
index ad2ca991..9b824133 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -466,6 +466,22 @@ function Layout() {
animation: "fade",
}}
/>
+
+
{
+ 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 (
+
+
+
+
+
+ {t("server.select_account")}
+
+
+ {modalState.server.name || modalState.server.address}
+
+
+ {isReady && (
+ <>
+
+ {modalState.server.accounts?.map((account, index) => (
+ {
+ modalState.onAccountSelect(account);
+ router.back();
+ }}
+ onLongPress={() => {
+ modalState.onDeleteAccount(account);
+ }}
+ hasTVPreferredFocus={index === 0}
+ />
+ ))}
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/app/tv-server-action-modal.tsx b/app/tv-server-action-modal.tsx
new file mode 100644
index 00000000..24a9637c
--- /dev/null
+++ b/app/tv-server-action-modal.tsx
@@ -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 (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+
+
+ {label}
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+ {/* Title */}
+
+ {modalState.server.name || modalState.server.address}
+
+
+ {/* Horizontal options */}
+ {isReady && (
+
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/components/Button.tsx b/components/Button.tsx
index 03e9296d..3b5d6351 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -132,7 +132,7 @@ export const Button: React.FC> = ({
= ({
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 = ({
{/* Security Icon */}
-
+
diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx
index 40c2b8d3..2e9435f1 100644
--- a/components/login/TVInput.tsx
+++ b/components/login/TVInput.tsx
@@ -58,20 +58,25 @@ export const TVInput: 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(null);
- // Server action sheet state
- const [showServerActionSheet, setShowServerActionSheet] = useState(false);
- const [actionSheetServer, setActionSheetServer] =
- useState(null);
+ // Server login trigger state
const [loginTriggerServer, setLoginTriggerServer] =
useState(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")} `}
- {serverName}
+ {serverName}
>
) : (
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")}
@@ -595,7 +573,7 @@ export const TVLogin: React.FC = () => {
{/* Logo */}
@@ -645,6 +623,7 @@ export const TVLogin: React.FC = () => {
onPress={() => handleConnect(serverURL)}
loading={loadingServerCheck}
disabled={loadingServerCheck || !serverURL.trim()}
+ color='white'
>
{t("server.connect_button")}
@@ -706,16 +685,6 @@ export const TVLogin: React.FC = () => {
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
-
- {/* Server Action Sheet */}
- setShowServerActionSheet(false)}
- />
);
};
diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx
index 25d9ce74..821bf689 100644
--- a/components/login/TVPINEntryModal.tsx
+++ b/components/login/TVPINEntryModal.tsx
@@ -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<{
diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx
index 1473cf86..3e5574a6 100644
--- a/components/login/TVPasswordEntryModal.tsx
+++ b/components/login/TVPasswordEntryModal.tsx
@@ -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 ? (
-
+
) : (
<>
-
+
@@ -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,
},
diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx
index 1903709e..cc9ad1ff 100644
--- a/components/login/TVPreviousServersList.tsx
+++ b/components/login/TVPreviousServersList.tsx
@@ -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 (
- {
- setFocused(true);
- animateTo(1.05);
- }}
- onBlur={() => {
- setFocused(false);
- animateTo(1);
- }}
- hasTVPreferredFocus={hasTVPreferredFocus}
- >
-
-
-
- {label}
-
-
-
- );
-};
-
-// 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 (
-
-
-
-
- {/* Title */}
-
- {server.name || server.address}
-
-
- {/* Horizontal options */}
-
-
-
-
-
-
-
-
-
- );
-};
-
interface TVPreviousServersListProps {
onServerSelect: (server: SavedServer) => void;
onQuickLogin?: (serverUrl: string, userId: string) => Promise;
@@ -227,9 +37,6 @@ interface TVPreviousServersListProps {
disabled?: boolean;
}
-// Export the action sheet for use in parent components
-export { TVServerActionSheet };
-
export const TVPreviousServersList: React.FC = ({
onServerSelect,
onQuickLogin,
@@ -241,37 +48,16 @@ export const TVPreviousServersList: React.FC = ({
disabled = false,
}) => {
const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+ const { showAccountSelectModal } = useTVAccountSelectModal();
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState(null);
- const [selectedServer, setSelectedServer] = useState(
- 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 = ({
server: SavedServer,
account: SavedServerAccount,
) => {
- setShowAccountsModal(false);
-
switch (account.securityType) {
case "none":
if (onQuickLogin) {
@@ -315,6 +99,58 @@ export const TVPreviousServersList: React.FC = ({
}
};
+ 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 = ({
} 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 = ({
}
};
- 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 (
= ({
/>
))}
-
- {/* TV Account Selection Modal */}
- setShowAccountsModal(false)}
- >
-
-
-
- {t("server.select_account")}
-
-
- {selectedServer?.name || selectedServer?.address}
-
-
-
- {selectedServer?.accounts.map((account, index) => (
-
- selectedServer &&
- handleAccountLogin(selectedServer, account)
- }
- onLongPress={() => handleDeleteAccount(account)}
- hasTVPreferredFocus={index === 0}
- />
- ))}
-
-
-
-
-
-
-
-
-
);
};
diff --git a/components/login/TVSaveAccountModal.tsx b/components/login/TVSaveAccountModal.tsx
index a1c7d55c..39f4fb8c 100644
--- a/components/login/TVSaveAccountModal.tsx
+++ b/components/login/TVSaveAccountModal.tsx
@@ -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<{
},
]}
>
-
+
diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx
index 85ccc3f1..fc843256 100644
--- a/components/login/TVSaveAccountToggle.tsx
+++ b/components/login/TVSaveAccountToggle.tsx
@@ -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 = ({
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 = ({
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 = ({
width: 28,
height: 28,
borderRadius: 14,
- backgroundColor: "white",
+ backgroundColor: value ? "#000" : "#fff",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>
diff --git a/components/login/TVServerCard.tsx b/components/login/TVServerCard.tsx
index b75b91ac..4325cdd6 100644
--- a/components/login/TVServerCard.tsx
+++ b/components/login/TVServerCard.tsx
@@ -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 = ({
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 = ({
{isLoading ? (
-
+
) : securityIcon ? (
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 };
+};
diff --git a/hooks/useTVServerActionModal.ts b/hooks/useTVServerActionModal.ts
new file mode 100644
index 00000000..f0da43f1
--- /dev/null
+++ b/hooks/useTVServerActionModal.ts
@@ -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 };
+};
diff --git a/utils/atoms/tvAccountSelectModal.ts b/utils/atoms/tvAccountSelectModal.ts
new file mode 100644
index 00000000..3cafa61e
--- /dev/null
+++ b/utils/atoms/tvAccountSelectModal.ts
@@ -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(null);
diff --git a/utils/atoms/tvServerActionModal.ts b/utils/atoms/tvServerActionModal.ts
new file mode 100644
index 00000000..38d99e83
--- /dev/null
+++ b/utils/atoms/tvServerActionModal.ts
@@ -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(null);