diff --git a/CLAUDE.md b/CLAUDE.md index 0c037d42..357616b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,7 +168,7 @@ import { apiAtom } from "@/providers/JellyfinProvider"; - **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance. - **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency. - **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping. -- **TV Modals**: Never use overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference. +- **TV Modals**: Never use React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.** ### TV Component Rendering Pattern @@ -196,98 +196,9 @@ export default LoginPage; - TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling - **Never use `.tv.tsx` file suffix** - it will not be resolved correctly -### TV Option Selector Pattern (Dropdowns/Multi-select) +### TV Option Selectors and Focus Management -For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because: -- Horizontal scrolling is natural for TV remotes (left/right D-pad) -- Bottom sheet takes minimal screen space -- Focus-based navigation works reliably - -**Key implementation details:** - -1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain. Use an absolutely positioned `View` overlay instead: -```typescript - - - {/* Content */} - - -``` - -2. **Horizontal ScrollView with focusable cards**: -```typescript - - {options.map((option, index) => ( - { onSelect(option.value); onClose(); }} - // ... - /> - ))} - -``` - -3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`: -```typescript - { setFocused(true); animateTo(1.05); }} - onBlur={() => { setFocused(false); animateTo(1); }} - hasTVPreferredFocus={hasTVPreferredFocus} -> - - {label} - - -``` - -4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip. - -**Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx` - -### TV Focus Management for Overlays/Modals - -**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation. - -**Solution**: Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible: - -```typescript -// 1. Track modal state -const [openModal, setOpenModal] = useState(null); -const isModalOpen = openModal !== null; - -// 2. Each focusable component accepts disabled prop -const TVFocusableButton: React.FC<{ - onPress: () => void; - disabled?: boolean; -}> = ({ onPress, disabled }) => ( - - {/* content */} - -); - -// 3. Pass disabled to all background components when modal is open - -``` - -**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc. +For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md). ### TV Focus Flickering Between Zones (Lists with Headers) diff --git a/app/_layout.tsx b/app/_layout.tsx index a3dc6240..43fe2186 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -473,7 +473,7 @@ function Layout() { }} /> @@ -105,10 +104,10 @@ const TVServerActionCard: React.FC<{ ); }; -export default function TVServerActionModalPage() { +export default function TVAccountActionModalPage() { const typography = useScaledTVTypography(); const router = useRouter(); - const modalState = useAtomValue(tvServerActionModalAtom); + const modalState = useAtomValue(tvAccountActionModalAtom); const { t } = useTranslation(); const [isReady, setIsReady] = useState(false); @@ -138,7 +137,7 @@ export default function TVServerActionModalPage() { const timer = setTimeout(() => setIsReady(true), 100); return () => { clearTimeout(timer); - store.set(tvServerActionModalAtom, null); + store.set(tvAccountActionModalAtom, null); }; }, [overlayOpacity, sheetTranslateY]); @@ -152,10 +151,6 @@ export default function TVServerActionModalPage() { router.back(); }; - const handleClose = () => { - router.back(); - }; - if (!modalState) { return null; } @@ -196,16 +191,27 @@ export default function TVServerActionModalPage() { overflow: "visible", }} > - {/* Title */} + {/* Account username as title */} + + {modalState.account.username} + + + {/* Server name as subtitle */} {modalState.server.name || modalState.server.address} @@ -223,23 +229,18 @@ export default function TVServerActionModalPage() { gap: 12, }} > - - - )} diff --git a/app/tv-account-select-modal.tsx b/app/tv-account-select-modal.tsx index ec8be5a5..a8a8e6bd 100644 --- a/app/tv-account-select-modal.tsx +++ b/app/tv-account-select-modal.tsx @@ -1,16 +1,108 @@ +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, TVFocusGuideView, View } from "react-native"; -import { Button } from "@/components/Button"; +import { + Animated, + Easing, + Pressable, + ScrollView, + TVFocusGuideView, +} from "react-native"; import { Text } from "@/components/common/Text"; -import { TVAccountCard } from "@/components/login/TVAccountCard"; +import { TVUserCard } from "@/components/tv/TVUserCard"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal"; import { store } from "@/utils/store"; +// Action button for bottom sheet +const TVAccountSelectAction: React.FC<{ + label: string; + icon: keyof typeof Ionicons.glyphMap; + variant?: "default" | "destructive"; + onPress: () => void; +}> = ({ label, icon, variant = "default", 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); + }} + > + + + + {label} + + + + ); +}; + export default function TVAccountSelectModalPage() { const typography = useScaledTVTypography(); const router = useRouter(); @@ -19,12 +111,12 @@ export default function TVAccountSelectModalPage() { const [isReady, setIsReady] = useState(false); const overlayOpacity = useRef(new Animated.Value(0)).current; - const contentScale = useRef(new Animated.Value(0.9)).current; + const sheetTranslateY = useRef(new Animated.Value(300)).current; // Animate in on mount useEffect(() => { overlayOpacity.setValue(0); - contentScale.setValue(0.9); + sheetTranslateY.setValue(300); Animated.parallel([ Animated.timing(overlayOpacity, { @@ -33,8 +125,8 @@ export default function TVAccountSelectModalPage() { easing: Easing.out(Easing.quad), useNativeDriver: true, }), - Animated.timing(contentScale, { - toValue: 1, + Animated.timing(sheetTranslateY, { + toValue: 0, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: true, @@ -46,11 +138,7 @@ export default function TVAccountSelectModalPage() { clearTimeout(timer); store.set(tvAccountSelectModalAtom, null); }; - }, [overlayOpacity, contentScale]); - - const handleClose = () => { - router.back(); - }; + }, [overlayOpacity, sheetTranslateY]); if (!modalState) { return null; @@ -60,25 +148,23 @@ export default function TVAccountSelectModalPage() { @@ -89,67 +175,78 @@ export default function TVAccountSelectModalPage() { trapFocusLeft trapFocusRight style={{ - padding: 40, + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", }} > + {/* Title */} {t("server.select_account")} + + {/* Server name as subtitle */} {modalState.server.name || modalState.server.address} + {/* All options in single horizontal row */} {isReady && ( - <> - - {modalState.server.accounts?.map((account, index) => ( - { - modalState.onAccountSelect(account); - router.back(); - }} - onLongPress={() => { - modalState.onDeleteAccount(account); - }} - hasTVPreferredFocus={index === 0} - /> - ))} - - - - - - - + hasTVPreferredFocus={index === 0} + /> + ))} + { + modalState.onAddAccount(); + router.back(); + }} + /> + { + modalState.onDeleteServer(); + router.back(); + }} + /> + )} diff --git a/components/PasswordEntryModal.tsx b/components/PasswordEntryModal.tsx index 63b4efe6..efd1cc49 100644 --- a/components/PasswordEntryModal.tsx +++ b/components/PasswordEntryModal.tsx @@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC = ({ {/* Password Input */} - {t("login.password")} + {t("login.password_placeholder")} = ({ setPassword(text); setError(null); }} - placeholder={t("login.password")} + placeholder={t("login.password_placeholder")} placeholderTextColor='#6B7280' secureTextEntry autoFocus @@ -174,7 +174,7 @@ export const PasswordEntryModal: React.FC = ({ {isLoading ? ( ) : ( - t("login.login") + t("common.login") )} diff --git a/components/login/TVAddIcon.tsx b/components/login/TVAddIcon.tsx new file mode 100644 index 00000000..111c706a --- /dev/null +++ b/components/login/TVAddIcon.tsx @@ -0,0 +1,82 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +export interface TVAddIconProps { + label: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVAddIcon = React.forwardRef( + ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + return ( + + + + + + + + {label} + + + + ); + }, +); diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx new file mode 100644 index 00000000..7d3cefe1 --- /dev/null +++ b/components/login/TVAddServerForm.tsx @@ -0,0 +1,162 @@ +import { Ionicons } from "@expo/vector-icons"; +import { t } from "i18next"; +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { TVInput } from "./TVInput"; + +interface TVAddServerFormProps { + onConnect: (url: string) => Promise; + onBack: () => void; + loading?: boolean; + disabled?: boolean; +} + +const TVBackButton: React.FC<{ + onPress: () => void; + label: string; + disabled?: boolean; +}> = ({ onPress, label, disabled = false }) => { + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.05 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + return ( + { + setIsFocused(true); + animateFocus(true); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + style={{ alignSelf: "flex-start", marginBottom: 24 }} + disabled={disabled} + focusable={!disabled} + > + + + + {label} + + + + ); +}; + +export const TVAddServerForm: React.FC = ({ + onConnect, + onBack, + loading = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const [serverURL, setServerURL] = useState(""); + + const handleConnect = async () => { + if (serverURL.trim()) { + await onConnect(serverURL.trim()); + } + }; + + const isDisabled = disabled || loading; + + return ( + + + {/* Back Button */} + + + {/* Server URL Input */} + + + + + {/* Connect Button */} + + + + + {/* Hint text */} + + {t("server.enter_url_to_jellyfin_server")} + + + + ); +}; diff --git a/components/login/TVAddUserForm.tsx b/components/login/TVAddUserForm.tsx new file mode 100644 index 00000000..17f8fe89 --- /dev/null +++ b/components/login/TVAddUserForm.tsx @@ -0,0 +1,230 @@ +import { Ionicons } from "@expo/vector-icons"; +import { t } from "i18next"; +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { TVInput } from "./TVInput"; +import { TVSaveAccountToggle } from "./TVSaveAccountToggle"; + +interface TVAddUserFormProps { + serverName: string; + serverAddress: string; + onLogin: ( + username: string, + password: string, + saveAccount: boolean, + ) => Promise; + onQuickConnect: () => Promise; + onBack: () => void; + loading?: boolean; + disabled?: boolean; +} + +const TVBackButton: React.FC<{ + onPress: () => void; + label: string; + disabled?: boolean; +}> = ({ onPress, label, disabled = false }) => { + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.05 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + return ( + { + setIsFocused(true); + animateFocus(true); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + style={{ alignSelf: "flex-start", marginBottom: 40 }} + disabled={disabled} + focusable={!disabled} + > + + + + {label} + + + + ); +}; + +export const TVAddUserForm: React.FC = ({ + serverName, + serverAddress, + onLogin, + onQuickConnect, + onBack, + loading = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const [credentials, setCredentials] = useState({ + username: "", + password: "", + }); + const [saveAccount, setSaveAccount] = useState(false); + + const handleLogin = async () => { + if (credentials.username.trim()) { + await onLogin(credentials.username, credentials.password, saveAccount); + } + }; + + const isDisabled = disabled || loading; + + return ( + + + {/* Back Button */} + + + {/* Title */} + + {serverName ? ( + <> + {`${t("login.login_to_title")} `} + {serverName} + + ) : ( + t("login.login_title") + )} + + + {serverAddress} + + + {/* Username Input */} + + + setCredentials((prev) => ({ ...prev, username: text })) + } + autoCapitalize='none' + autoCorrect={false} + textContentType='username' + returnKeyType='next' + hasTVPreferredFocus + disabled={isDisabled} + /> + + + {/* Password Input */} + + + setCredentials((prev) => ({ ...prev, password: text })) + } + secureTextEntry + autoCapitalize='none' + textContentType='password' + returnKeyType='done' + disabled={isDisabled} + /> + + + {/* Save Account Toggle */} + + + + + {/* Login Button */} + + + + + {/* Quick Connect Button */} + + + + ); +}; diff --git a/components/login/TVBackIcon.tsx b/components/login/TVBackIcon.tsx new file mode 100644 index 00000000..8cbc08d9 --- /dev/null +++ b/components/login/TVBackIcon.tsx @@ -0,0 +1,82 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +export interface TVBackIconProps { + label: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVBackIcon = React.forwardRef( + ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + return ( + + + + + + + + {label} + + + + ); + }, +); diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index ddd83aa0..8af6a1de 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -1,107 +1,37 @@ -import { Ionicons } from "@expo/vector-icons"; import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; -import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { t } from "i18next"; -import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Alert, - Animated, - Easing, - Pressable, - ScrollView, - View, -} from "react-native"; -import { z } from "zod"; -import { Button } from "@/components/Button"; -import { Text } from "@/components/common/Text"; -import { TVInput } from "@/components/login/TVInput"; -import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; -import { TVPINEntryModal } from "@/components/login/TVPINEntryModal"; -import { TVPreviousServersList } from "@/components/login/TVPreviousServersList"; -import { TVSaveAccountModal } from "@/components/login/TVSaveAccountModal"; -import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle"; -import { useTVServerActionModal } from "@/hooks/useTVServerActionModal"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, View } from "react-native"; +import { useMMKVString } from "react-native-mmkv"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; +import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer"; import { type AccountSecurityType, + getPreviousServers, removeServerFromList, type SavedServer, type SavedServerAccount, } from "@/utils/secureCredentials"; +import { TVAddServerForm } from "./TVAddServerForm"; +import { TVAddUserForm } from "./TVAddUserForm"; +import { TVPasswordEntryModal } from "./TVPasswordEntryModal"; +import { TVPINEntryModal } from "./TVPINEntryModal"; +import { TVSaveAccountModal } from "./TVSaveAccountModal"; +import { TVServerSelectionScreen } from "./TVServerSelectionScreen"; +import { TVUserSelectionScreen } from "./TVUserSelectionScreen"; -const CredentialsSchema = z.object({ - username: z.string().min(1, t("login.username_required")), -}); - -const TVBackButton: React.FC<{ - onPress: () => void; - label: string; - disabled?: boolean; -}> = ({ onPress, label, disabled = false }) => { - const [isFocused, setIsFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateFocus = (focused: boolean) => { - Animated.timing(scale, { - toValue: focused ? 1.05 : 1, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - }; - - return ( - { - setIsFocused(true); - animateFocus(true); - }} - onBlur={() => { - setIsFocused(false); - animateFocus(false); - }} - style={{ alignSelf: "flex-start", marginBottom: 40 }} - disabled={disabled} - focusable={!disabled} - > - - - - {label} - - - - ); -}; +type TVLoginScreen = + | "server-selection" + | "user-selection" + | "add-server" + | "add-user"; export const TVLogin: React.FC = () => { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); - const { showServerActionModal } = useTVServerActionModal(); const { setServer, login, @@ -117,20 +47,33 @@ export const TVLogin: React.FC = () => { password: _password, } = params as { apiUrl: string; username: string; password: string }; + // Selected server persistence + const [selectedTVServer, setSelectedTVServer] = useAtom(selectedTVServerAtom); + const [_previousServers, setPreviousServers] = + useMMKVString("previousServers"); + + // Get current servers list + const previousServers = useMemo(() => { + try { + return JSON.parse(_previousServers || "[]") as SavedServer[]; + } catch { + return []; + } + }, [_previousServers]); + + // Current screen state + const [currentScreen, setCurrentScreen] = + useState("server-selection"); + + // Current selected server for user selection screen + const [currentServer, setCurrentServer] = useState(null); + const [serverName, setServerName] = useState(""); + + // Loading states const [loadingServerCheck, setLoadingServerCheck] = useState(false); const [loading, setLoading] = useState(false); - const [serverURL, setServerURL] = useState(_apiUrl || ""); - const [serverName, setServerName] = useState(""); - const [credentials, setCredentials] = useState<{ - username: string; - password: string; - }>({ - username: _username || "", - password: _password || "", - }); // Save account state - const [saveAccount, setSaveAccount] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false); const [pendingLogin, setPendingLogin] = useState<{ username: string; @@ -140,20 +83,37 @@ export const TVLogin: React.FC = () => { // PIN/Password entry for saved accounts const [pinModalVisible, setPinModalVisible] = useState(false); const [passwordModalVisible, setPasswordModalVisible] = useState(false); - const [selectedServer, setSelectedServer] = useState( - null, - ); const [selectedAccount, setSelectedAccount] = useState(null); - // Server login trigger state - const [loginTriggerServer, setLoginTriggerServer] = - useState(null); - // Track if any modal is open to disable background focus const isAnyModalOpen = showSaveModal || pinModalVisible || passwordModalVisible; + // Refresh servers list helper + const refreshServers = () => { + const servers = getPreviousServers(); + setPreviousServers(JSON.stringify(servers)); + }; + + // Initialize on mount - check if we have a persisted server + useEffect(() => { + if (selectedTVServer) { + // Find the full server data from previousServers + const server = previousServers.find( + (s) => s.address === selectedTVServer.address, + ); + if (server) { + setCurrentServer(server); + setServerName(selectedTVServer.name || ""); + setCurrentScreen("user-selection"); + } else { + // Server no longer exists, clear persistence + setSelectedTVServer(null); + } + } + }, []); + // Auto login from URL params useEffect(() => { (async () => { @@ -161,7 +121,6 @@ export const TVLogin: React.FC = () => { await setServer({ address: _apiUrl }); setTimeout(() => { if (_username && _password) { - setCredentials({ username: _username, password: _password }); login(_username, _password); } }, 0); @@ -177,169 +136,7 @@ export const TVLogin: React.FC = () => { }); }, [serverName, navigation]); - const handleLogin = async () => { - const result = CredentialsSchema.safeParse(credentials); - if (!result.success) return; - - if (saveAccount) { - setPendingLogin({ - username: credentials.username, - password: credentials.password, - }); - setShowSaveModal(true); - } else { - await performLogin(credentials.username, credentials.password); - } - }; - - const performLogin = async ( - username: string, - password: string, - options?: { - saveAccount?: boolean; - securityType?: AccountSecurityType; - pinCode?: string; - }, - ) => { - setLoading(true); - try { - await login(username, password, serverName, options); - } catch (error) { - if (error instanceof Error) { - Alert.alert(t("login.connection_failed"), error.message); - } else { - Alert.alert( - t("login.connection_failed"), - t("login.an_unexpected_error_occured"), - ); - } - } finally { - setLoading(false); - setPendingLogin(null); - } - }; - - const handleSaveAccountConfirm = async ( - securityType: AccountSecurityType, - pinCode?: string, - ) => { - setShowSaveModal(false); - if (pendingLogin) { - await performLogin(pendingLogin.username, pendingLogin.password, { - saveAccount: true, - securityType, - pinCode, - }); - } - }; - - const handleQuickLoginWithSavedCredential = async ( - serverUrl: string, - userId: string, - ) => { - await loginWithSavedCredential(serverUrl, userId); - }; - - const handlePasswordLogin = async ( - serverUrl: string, - username: string, - password: string, - ) => { - await loginWithPassword(serverUrl, username, password); - }; - - const handleAddAccount = (server: SavedServer) => { - setServer({ address: server.address }); - if (server.name) { - setServerName(server.name); - } - }; - - const handlePinRequired = ( - server: SavedServer, - account: SavedServerAccount, - ) => { - setSelectedServer(server); - setSelectedAccount(account); - setPinModalVisible(true); - }; - - const handlePasswordRequired = ( - server: SavedServer, - account: SavedServerAccount, - ) => { - setSelectedServer(server); - setSelectedAccount(account); - setPasswordModalVisible(true); - }; - - const handlePinSuccess = async () => { - setPinModalVisible(false); - if (selectedServer && selectedAccount) { - await handleQuickLoginWithSavedCredential( - selectedServer.address, - selectedAccount.userId, - ); - } - setSelectedServer(null); - setSelectedAccount(null); - }; - - const handlePasswordSubmit = async (password: string) => { - if (selectedServer && selectedAccount) { - await handlePasswordLogin( - selectedServer.address, - selectedAccount.username, - password, - ); - } - setPasswordModalVisible(false); - setSelectedServer(null); - setSelectedAccount(null); - }; - - const handleForgotPIN = async () => { - if (selectedServer) { - setSelectedServer(null); - setSelectedAccount(null); - setPinModalVisible(false); - } - }; - - // Server action sheet handler - const handleServerAction = (server: SavedServer) => { - showServerActionModal({ - server, - onLogin: () => { - // Trigger the login flow in TVPreviousServersList - setLoginTriggerServer(server); - // Reset the trigger after a tick to allow re-triggering the same server - setTimeout(() => setLoginTriggerServer(null), 0); - }, - onDelete: () => { - Alert.alert( - t("server.remove_server"), - t("server.remove_server_description", { - server: server.name || server.address, - }), - [ - { - text: t("common.cancel"), - style: "cancel", - }, - { - text: t("common.delete"), - style: "destructive", - onPress: async () => { - await removeServerFromList(server.address); - }, - }, - ], - ); - }, - }); - }; - + // Server URL checking const checkUrl = useCallback(async (url: string) => { setLoadingServerCheck(true); const baseUrl = url.replace(/^https?:\/\//i, ""); @@ -387,27 +184,214 @@ export const TVLogin: React.FC = () => { return undefined; } - const handleConnect = useCallback(async (url: string) => { - url = url.trim().replace(/\/$/, ""); - console.log("[TVLogin] handleConnect called with:", url); - try { - const result = await checkUrl(url); - console.log("[TVLogin] checkUrl result:", result); - if (result === undefined) { + // Handle connecting to a new server + const handleConnect = useCallback( + async (url: string) => { + url = url.trim().replace(/\/$/, ""); + try { + const result = await checkUrl(url); + if (result === undefined) { + Alert.alert( + t("login.connection_failed"), + t("login.could_not_connect_to_server"), + ); + return; + } + await setServer({ address: result }); + + // Update server list and get the new server data + refreshServers(); + + // Find or create server entry + const servers = getPreviousServers(); + const server = servers.find((s) => s.address === result); + + if (server) { + setCurrentServer(server); + setSelectedTVServer({ address: result, name: serverName }); + setCurrentScreen("user-selection"); + } + } catch (error) { + console.error("[TVLogin] Error in handleConnect:", error); + } + }, + [checkUrl, setServer, serverName, setSelectedTVServer], + ); + + // Handle selecting an existing server + const handleServerSelect = (server: SavedServer) => { + setCurrentServer(server); + setServerName(server.name || ""); + setSelectedTVServer({ address: server.address, name: server.name }); + setCurrentScreen("user-selection"); + }; + + // Handle changing server (back from user selection) + const handleChangeServer = () => { + setSelectedTVServer(null); + setCurrentServer(null); + setServerName(""); + removeServer(); + setCurrentScreen("server-selection"); + }; + + // Handle deleting a server + const handleDeleteServer = async (server: SavedServer) => { + await removeServerFromList(server.address); + refreshServers(); + // If we deleted the currently selected server, clear it + if (selectedTVServer?.address === server.address) { + setSelectedTVServer(null); + setCurrentServer(null); + } + }; + + // Handle user selection + const handleUserSelect = async (account: SavedServerAccount) => { + if (!currentServer) return; + + switch (account.securityType) { + case "none": + setLoading(true); + try { + await loginWithSavedCredential(currentServer.address, account.userId); + } catch { + Alert.alert( + t("server.session_expired"), + t("server.please_login_again"), + [ + { + text: t("common.ok"), + onPress: () => setCurrentScreen("add-user"), + }, + ], + ); + } finally { + setLoading(false); + } + break; + + case "pin": + setSelectedAccount(account); + setPinModalVisible(true); + break; + + case "password": + setSelectedAccount(account); + setPasswordModalVisible(true); + break; + } + }; + + // Handle PIN success + const handlePinSuccess = async () => { + setPinModalVisible(false); + if (currentServer && selectedAccount) { + setLoading(true); + try { + await loginWithSavedCredential( + currentServer.address, + selectedAccount.userId, + ); + } catch { + Alert.alert( + t("server.session_expired"), + t("server.please_login_again"), + ); + } finally { + setLoading(false); + } + } + setSelectedAccount(null); + }; + + // Handle password submit + const handlePasswordSubmit = async (password: string) => { + if (currentServer && selectedAccount) { + setLoading(true); + try { + await loginWithPassword( + currentServer.address, + selectedAccount.username, + password, + ); + } catch { Alert.alert( t("login.connection_failed"), - t("login.could_not_connect_to_server"), + t("login.invalid_username_or_password"), ); - return; + } finally { + setLoading(false); } - console.log("[TVLogin] Calling setServer with:", result); - await setServer({ address: result }); - console.log("[TVLogin] setServer completed successfully"); - } catch (error) { - console.error("[TVLogin] Error in handleConnect:", error); } - }, []); + setPasswordModalVisible(false); + setSelectedAccount(null); + }; + // Handle forgot PIN + const handleForgotPIN = async () => { + setSelectedAccount(null); + setPinModalVisible(false); + }; + + // Handle login with credentials (from add user form) + const handleLogin = async ( + username: string, + password: string, + saveAccount: boolean, + ) => { + if (!currentServer) return; + + if (saveAccount) { + setPendingLogin({ username, password }); + setShowSaveModal(true); + } else { + await performLogin(username, password); + } + }; + + const performLogin = async ( + username: string, + password: string, + options?: { + saveAccount?: boolean; + securityType?: AccountSecurityType; + pinCode?: string; + }, + ) => { + setLoading(true); + try { + await login(username, password, serverName, options); + } catch (error) { + if (error instanceof Error) { + Alert.alert(t("login.connection_failed"), error.message); + } else { + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occured"), + ); + } + } finally { + setLoading(false); + setPendingLogin(null); + } + }; + + const handleSaveAccountConfirm = async ( + securityType: AccountSecurityType, + pinCode?: string, + ) => { + setShowSaveModal(false); + if (pendingLogin) { + await performLogin(pendingLogin.username, pendingLogin.password, { + saveAccount: true, + securityType, + pinCode, + }); + } + }; + + // Handle quick connect const handleQuickConnect = async () => { try { const code = await initiateQuickConnect(); @@ -426,227 +410,89 @@ export const TVLogin: React.FC = () => { } }; - // Debug logging - console.log("[TVLogin] Render - api?.basePath:", api?.basePath); + // Render current screen + const renderScreen = () => { + // If API is connected but we're on server/user selection, + // it means we need to show add-user form + if (api?.basePath && currentScreen !== "add-user") { + // API is ready, show add-user form + return ( + + ); + } + + switch (currentScreen) { + case "server-selection": + return ( + setCurrentScreen("add-server")} + onDeleteServer={handleDeleteServer} + disabled={isAnyModalOpen} + /> + ); + + case "user-selection": + if (!currentServer) { + setCurrentScreen("server-selection"); + return null; + } + return ( + { + // Set the server in JellyfinProvider and go to add-user + setServer({ address: currentServer.address }); + setCurrentScreen("add-user"); + }} + onChangeServer={handleChangeServer} + disabled={isAnyModalOpen || loading} + /> + ); + + case "add-server": + return ( + setCurrentScreen("server-selection")} + loading={loadingServerCheck} + disabled={isAnyModalOpen} + /> + ); + + case "add-user": + return ( + { + removeServer(); + setCurrentScreen("user-selection"); + }} + loading={loading} + disabled={isAnyModalOpen} + /> + ); + + default: + return null; + } + }; return ( - - {api?.basePath ? ( - // ==================== CREDENTIALS SCREEN ==================== - - - {/* Back Button */} - removeServer()} - label={t("login.change_server")} - disabled={isAnyModalOpen} - /> - - {/* Title */} - - {serverName ? ( - <> - {`${t("login.login_to_title")} `} - {serverName} - - ) : ( - t("login.login_title") - )} - - - {api.basePath} - - - {/* Username Input - extra padding for focus scale */} - - - setCredentials((prev) => ({ ...prev, username: text })) - } - autoCapitalize='none' - autoCorrect={false} - textContentType='username' - returnKeyType='next' - hasTVPreferredFocus - disabled={isAnyModalOpen} - /> - - - {/* Password Input */} - - - setCredentials((prev) => ({ ...prev, password: text })) - } - secureTextEntry - autoCapitalize='none' - textContentType='password' - returnKeyType='done' - disabled={isAnyModalOpen} - /> - - - {/* Save Account Toggle */} - - - - - {/* Login Button */} - - - - - {/* Quick Connect Button */} - - - - ) : ( - // ==================== SERVER SELECTION SCREEN ==================== - - - {/* Logo */} - - - - - {/* Title */} - - Streamyfin - - - {t("server.enter_url_to_jellyfin_server")} - - - {/* Server URL Input - extra padding for focus scale */} - - - - - {/* Connect Button */} - - - - - {/* Previous Servers */} - - handleConnect(s.address)} - onQuickLogin={handleQuickLoginWithSavedCredential} - onPasswordLogin={handlePasswordLogin} - onAddAccount={handleAddAccount} - onPinRequired={handlePinRequired} - onPasswordRequired={handlePasswordRequired} - onServerAction={handleServerAction} - loginServerOverride={loginTriggerServer} - disabled={isAnyModalOpen} - /> - - - - )} - + {renderScreen()} {/* Save Account Modal */} { setPendingLogin(null); }} onSave={handleSaveAccountConfirm} - username={pendingLogin?.username || credentials.username} + username={pendingLogin?.username || ""} /> {/* PIN Entry Modal */} @@ -665,11 +511,10 @@ export const TVLogin: React.FC = () => { onClose={() => { setPinModalVisible(false); setSelectedAccount(null); - setSelectedServer(null); }} onSuccess={handlePinSuccess} onForgotPIN={handleForgotPIN} - serverUrl={selectedServer?.address || ""} + serverUrl={currentServer?.address || ""} userId={selectedAccount?.userId || ""} username={selectedAccount?.username || ""} /> @@ -680,7 +525,6 @@ export const TVLogin: React.FC = () => { onClose={() => { setPasswordModalVisible(false); setSelectedAccount(null); - setSelectedServer(null); }} onSubmit={handlePasswordSubmit} username={selectedAccount?.username || ""} diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx index 821bf689..415cf2cf 100644 --- a/components/login/TVPINEntryModal.tsx +++ b/components/login/TVPINEntryModal.tsx @@ -1,3 +1,4 @@ +import { Ionicons } from "@expo/vector-icons"; import { BlurView } from "expo-blur"; import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -11,8 +12,6 @@ import { View, } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput"; -import { useTVFocusAnimation } from "@/components/tv"; import { verifyAccountPIN } from "@/utils/secureCredentials"; interface TVPINEntryModalProps { @@ -25,40 +24,122 @@ interface TVPINEntryModalProps { username: string; } -// Forgot PIN Button -const TVForgotPINButton: React.FC<{ +// Number pad button +const NumberPadButton: React.FC<{ + value: string; onPress: () => void; - label: string; hasTVPreferredFocus?: boolean; -}> = ({ onPress, label, hasTVPreferredFocus = false }) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + isBackspace?: boolean; + disabled?: boolean; +}> = ({ value, onPress, hasTVPreferredFocus, isBackspace, disabled }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 100, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); return ( { + setFocused(true); + animateTo(1.1); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} hasTVPreferredFocus={hasTVPreferredFocus} + disabled={disabled} + focusable={!disabled} > + {isBackspace ? ( + + ) : ( + + {value} + + )} + + + ); +}; + +// PIN dot indicator +const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({ + filled, + error, +}) => ( + +); + +// Forgot PIN link +const ForgotPINLink: React.FC<{ + onPress: () => void; + label: string; +}> = ({ onPress, label }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 100, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + > + {label} @@ -80,23 +161,21 @@ export const TVPINEntryModal: React.FC = ({ const { t } = useTranslation(); const [isReady, setIsReady] = useState(false); const [pinCode, setPinCode] = useState(""); - const [error, setError] = useState(null); + const [error, setError] = useState(false); const [isVerifying, setIsVerifying] = useState(false); - const pinInputRef = useRef(null); const overlayOpacity = useRef(new Animated.Value(0)).current; - const sheetTranslateY = useRef(new Animated.Value(200)).current; + const contentScale = useRef(new Animated.Value(0.9)).current; const shakeAnimation = useRef(new Animated.Value(0)).current; useEffect(() => { if (visible) { - // Reset state when opening setPinCode(""); - setError(null); + setError(false); setIsVerifying(false); overlayOpacity.setValue(0); - sheetTranslateY.setValue(200); + contentScale.setValue(0.9); Animated.parallel([ Animated.timing(overlayOpacity, { @@ -105,32 +184,19 @@ export const TVPINEntryModal: React.FC = ({ easing: Easing.out(Easing.quad), useNativeDriver: true, }), - Animated.timing(sheetTranslateY, { - toValue: 0, + Animated.timing(contentScale, { + toValue: 1, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: true, }), ]).start(); - } - }, [visible, overlayOpacity, sheetTranslateY]); - useEffect(() => { - if (visible) { const timer = setTimeout(() => setIsReady(true), 100); return () => clearTimeout(timer); } setIsReady(false); - }, [visible]); - - useEffect(() => { - if (visible && isReady) { - const timer = setTimeout(() => { - pinInputRef.current?.focus(); - }, 150); - return () => clearTimeout(timer); - } - }, [visible, isReady]); + }, [visible, overlayOpacity, contentScale]); const shake = () => { Animated.sequence([ @@ -157,33 +223,42 @@ export const TVPINEntryModal: React.FC = ({ ]).start(); }; - const handlePinChange = async (value: string) => { - setPinCode(value); - setError(null); + const handleNumberPress = async (num: string) => { + if (isVerifying || pinCode.length >= 4) return; + + setError(false); + const newPin = pinCode + num; + setPinCode(newPin); // Auto-verify when 4 digits entered - if (value.length === 4) { + if (newPin.length === 4) { setIsVerifying(true); try { - const isValid = await verifyAccountPIN(serverUrl, userId, value); + const isValid = await verifyAccountPIN(serverUrl, userId, newPin); if (isValid) { onSuccess(); setPinCode(""); } else { - setError(t("pin.invalid_pin")); + setError(true); shake(); - setPinCode(""); + setTimeout(() => setPinCode(""), 300); } } catch { - setError(t("pin.invalid_pin")); + setError(true); shake(); - setPinCode(""); + setTimeout(() => setPinCode(""), 300); } finally { setIsVerifying(false); } } }; + const handleBackspace = () => { + if (isVerifying) return; + setError(false); + setPinCode((prev) => prev.slice(0, -1)); + }; + const handleForgotPIN = () => { Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [ { text: t("common.cancel"), style: "cancel" }, @@ -204,11 +279,11 @@ export const TVPINEntryModal: React.FC = ({ - + = ({ style={styles.content} > {/* Header */} - - {t("pin.enter_pin")} - - {t("pin.enter_pin_for", { username })} - - + {t("pin.enter_pin")} + {username} - {/* PIN Input */} + {/* PIN Dots */} + + {[0, 1, 2, 3].map((i) => ( + i} error={error} /> + ))} + + + {/* Number Pad */} {isReady && ( - - - {error && {error}} - {isVerifying && ( - - {t("common.verifying")} - - )} - + + {/* Row 1: 1-3 */} + + handleNumberPress("1")} + hasTVPreferredFocus + disabled={isVerifying} + /> + handleNumberPress("2")} + disabled={isVerifying} + /> + handleNumberPress("3")} + disabled={isVerifying} + /> + + {/* Row 2: 4-6 */} + + handleNumberPress("4")} + disabled={isVerifying} + /> + handleNumberPress("5")} + disabled={isVerifying} + /> + handleNumberPress("6")} + disabled={isVerifying} + /> + + {/* Row 3: 7-9 */} + + handleNumberPress("7")} + disabled={isVerifying} + /> + handleNumberPress("8")} + disabled={isVerifying} + /> + handleNumberPress("9")} + disabled={isVerifying} + /> + + {/* Row 4: empty, 0, backspace */} + + + handleNumberPress("0")} + disabled={isVerifying} + /> + + + )} {/* Forgot PIN */} {isReady && onForgotPIN && ( - )} @@ -273,55 +407,81 @@ const styles = StyleSheet.create({ left: 0, right: 0, bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.5)", - justifyContent: "flex-end", + backgroundColor: "rgba(0, 0, 0, 0.8)", + justifyContent: "center", + alignItems: "center", zIndex: 1000, }, - sheetContainer: { + contentContainer: { width: "100%", + maxWidth: 400, }, blurContainer: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, + borderRadius: 24, overflow: "hidden", }, content: { - paddingTop: 24, - paddingBottom: 50, - overflow: "visible", - }, - header: { - paddingHorizontal: 48, - marginBottom: 24, + padding: 40, + alignItems: "center", }, title: { fontSize: 28, fontWeight: "bold", color: "#fff", - marginBottom: 4, + marginBottom: 8, + textAlign: "center", }, subtitle: { - fontSize: 16, + fontSize: 18, color: "rgba(255,255,255,0.6)", + marginBottom: 32, + textAlign: "center", }, - pinContainer: { - paddingHorizontal: 48, + pinDotsContainer: { + flexDirection: "row", + gap: 16, + marginBottom: 32, + }, + pinDot: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderColor: "rgba(255,255,255,0.4)", + backgroundColor: "transparent", + }, + pinDotFilled: { + backgroundColor: "#fff", + borderColor: "#fff", + }, + pinDotError: { + borderColor: "#ef4444", + backgroundColor: "#ef4444", + }, + numberPad: { + gap: 12, + marginBottom: 24, + }, + numberRow: { + flexDirection: "row", + gap: 12, + }, + numberButton: { + width: 72, + height: 72, + borderRadius: 36, + justifyContent: "center", alignItems: "center", - marginBottom: 16, }, - errorText: { - color: "#ef4444", - fontSize: 14, - marginTop: 16, - textAlign: "center", + numberButtonPlaceholder: { + width: 72, + height: 72, }, - verifyingText: { - color: "rgba(255,255,255,0.6)", - fontSize: 14, - marginTop: 16, - textAlign: "center", + numberText: { + fontSize: 28, + fontWeight: "600", }, forgotContainer: { - alignItems: "center", + marginTop: 8, }, }); diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx index 3e5574a6..e4f0c358 100644 --- a/components/login/TVPasswordEntryModal.tsx +++ b/components/login/TVPasswordEntryModal.tsx @@ -249,14 +249,16 @@ export const TVPasswordEntryModal: React.FC = ({ {/* Password Input */} {isReady && ( - {t("login.password")} + + {t("login.password_placeholder")} + { setPassword(text); setError(null); }} - placeholder={t("login.password")} + placeholder={t("login.password_placeholder")} onSubmitEditing={handleSubmit} hasTVPreferredFocus /> diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx deleted file mode 100644 index cc9ad1ff..00000000 --- a/components/login/TVPreviousServersList.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type React from "react"; -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Alert, View } from "react-native"; -import { useMMKVString } from "react-native-mmkv"; -import { Text } from "@/components/common/Text"; -import { useScaledTVTypography } from "@/constants/TVTypography"; -import { useTVAccountSelectModal } from "@/hooks/useTVAccountSelectModal"; -import { - deleteAccountCredential, - getPreviousServers, - type SavedServer, - type SavedServerAccount, -} from "@/utils/secureCredentials"; -import { TVServerCard } from "./TVServerCard"; - -interface TVPreviousServersListProps { - onServerSelect: (server: SavedServer) => void; - onQuickLogin?: (serverUrl: string, userId: string) => Promise; - onPasswordLogin?: ( - serverUrl: string, - username: string, - password: string, - ) => Promise; - onAddAccount?: (server: SavedServer) => void; - onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void; - onPasswordRequired?: ( - server: SavedServer, - account: SavedServerAccount, - ) => void; - // Called when server is pressed to show action sheet (handled by parent) - onServerAction?: (server: SavedServer) => void; - // Called by parent when "Login" is selected from action sheet - loginServerOverride?: SavedServer | null; - // Disable all focusable elements (when a modal is open) - disabled?: boolean; -} - -export const TVPreviousServersList: React.FC = ({ - onServerSelect, - onQuickLogin, - onAddAccount, - onPinRequired, - onPasswordRequired, - onServerAction, - loginServerOverride, - disabled = false, -}) => { - const { t } = useTranslation(); - const typography = useScaledTVTypography(); - const { showAccountSelectModal } = useTVAccountSelectModal(); - const [_previousServers, setPreviousServers] = - useMMKVString("previousServers"); - const [loadingServer, setLoadingServer] = useState(null); - - const previousServers = useMemo(() => { - return JSON.parse(_previousServers || "[]") as SavedServer[]; - }, [_previousServers]); - - const refreshServers = () => { - const servers = getPreviousServers(); - setPreviousServers(JSON.stringify(servers)); - }; - - const handleAccountLogin = async ( - server: SavedServer, - account: SavedServerAccount, - ) => { - switch (account.securityType) { - case "none": - if (onQuickLogin) { - setLoadingServer(server.address); - try { - await onQuickLogin(server.address, account.userId); - } catch { - Alert.alert( - t("server.session_expired"), - t("server.please_login_again"), - [{ text: t("common.ok"), onPress: () => onServerSelect(server) }], - ); - } finally { - setLoadingServer(null); - } - } - break; - - case "pin": - if (onPinRequired) { - onPinRequired(server, account); - } - break; - - case "password": - if (onPasswordRequired) { - onPasswordRequired(server, account); - } - break; - } - }; - - const handleDeleteAccount = async ( - server: SavedServer, - account: SavedServerAccount, - ) => { - Alert.alert( - t("server.remove_saved_login"), - t("server.remove_account_description", { username: account.username }), - [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("common.remove"), - style: "destructive", - onPress: async () => { - await deleteAccountCredential(server.address, account.userId); - refreshServers(); - }, - }, - ], - ); - }; - - const showAccountSelection = (server: SavedServer) => { - showAccountSelectModal({ - server, - onAccountSelect: (account) => handleAccountLogin(server, account), - onAddAccount: () => { - if (onAddAccount) { - onAddAccount(server); - } - }, - onDeleteAccount: (account) => handleDeleteAccount(server, account), - }); - }; - - // When parent triggers login via loginServerOverride, execute the login flow - useEffect(() => { - if (loginServerOverride) { - const accountCount = loginServerOverride.accounts?.length || 0; - - if (accountCount === 0) { - onServerSelect(loginServerOverride); - } else if (accountCount === 1) { - handleAccountLogin( - loginServerOverride, - loginServerOverride.accounts[0], - ); - } else { - showAccountSelection(loginServerOverride); - } - } - }, [loginServerOverride]); - - const handleServerPress = (server: SavedServer) => { - if (loadingServer) return; - - // If onServerAction is provided, delegate to parent for action sheet handling - if (onServerAction) { - onServerAction(server); - return; - } - - // Fallback: direct login flow (for backwards compatibility) - const accountCount = server.accounts?.length || 0; - if (accountCount === 0) { - onServerSelect(server); - } else if (accountCount === 1) { - handleAccountLogin(server, server.accounts[0]); - } else { - showAccountSelection(server); - } - }; - - const getServerSubtitle = (server: SavedServer): string | undefined => { - const accountCount = server.accounts?.length || 0; - - if (accountCount > 1) { - return t("server.accounts_count", { count: accountCount }); - } - if (accountCount === 1) { - return `${server.accounts[0].username} • ${t("server.saved")}`; - } - return server.name ? server.address : undefined; - }; - - const getSecurityIcon = ( - server: SavedServer, - ): keyof typeof Ionicons.glyphMap | null => { - const accountCount = server.accounts?.length || 0; - if (accountCount === 0) return null; - - if (accountCount > 1) { - return "people"; - } - - const account = server.accounts[0]; - switch (account.securityType) { - case "pin": - return "keypad"; - case "password": - return "lock-closed"; - default: - return "key"; - } - }; - - if (!previousServers.length) return null; - - return ( - - - {t("server.previous_servers")} - - - - {previousServers.map((server) => ( - handleServerPress(server)} - disabled={disabled} - /> - ))} - - - ); -}; diff --git a/components/login/TVServerCard.tsx b/components/login/TVServerCard.tsx deleted file mode 100644 index 4325cdd6..00000000 --- a/components/login/TVServerCard.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import React, { useRef, useState } from "react"; -import { - ActivityIndicator, - Animated, - Easing, - Pressable, - View, -} from "react-native"; -import { Text } from "@/components/common/Text"; - -interface TVServerCardProps { - title: string; - subtitle?: string; - securityIcon?: keyof typeof Ionicons.glyphMap | null; - isLoading?: boolean; - onPress: () => void; - hasTVPreferredFocus?: boolean; - disabled?: boolean; -} - -export const TVServerCard: React.FC = ({ - title, - subtitle, - securityIcon, - isLoading, - onPress, - hasTVPreferredFocus, - disabled = false, -}) => { - const [isFocused, setIsFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - const glowOpacity = useRef(new Animated.Value(0)).current; - - const animateFocus = (focused: boolean) => { - Animated.parallel([ - Animated.timing(scale, { - toValue: focused ? 1.02 : 1, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(glowOpacity, { - toValue: focused ? 0.7 : 0, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - ]).start(); - }; - - const handleFocus = () => { - setIsFocused(true); - animateFocus(true); - }; - - const handleBlur = () => { - setIsFocused(false); - animateFocus(false); - }; - - const isDisabled = disabled || isLoading; - - return ( - - - - - - {title} - - {subtitle && ( - - {subtitle} - - )} - - - - {isLoading ? ( - - ) : securityIcon ? ( - - - - - ) : ( - - )} - - - - - ); -}; diff --git a/components/login/TVServerIcon.tsx b/components/login/TVServerIcon.tsx new file mode 100644 index 00000000..bfad0baf --- /dev/null +++ b/components/login/TVServerIcon.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +export interface TVServerIconProps { + name: string; + address: string; + onPress: () => void; + onLongPress?: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVServerIcon = React.forwardRef( + ( + { + name, + address, + onPress, + onLongPress, + hasTVPreferredFocus, + disabled = false, + }, + ref, + ) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + // Get the first letter of the server name (or address if no name) + const displayName = name || address; + const initial = displayName.charAt(0).toUpperCase(); + + return ( + + + + + {initial} + + + + + {displayName} + + + {name && ( + + {address.replace(/^https?:\/\//, "")} + + )} + + + ); + }, +); diff --git a/components/login/TVServerSelectionScreen.tsx b/components/login/TVServerSelectionScreen.tsx new file mode 100644 index 00000000..feff46df --- /dev/null +++ b/components/login/TVServerSelectionScreen.tsx @@ -0,0 +1,137 @@ +import { Image } from "expo-image"; +import { t } from "i18next"; +import React, { useMemo } from "react"; +import { Alert, ScrollView, View } from "react-native"; +import { useMMKVString } from "react-native-mmkv"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import type { SavedServer } from "@/utils/secureCredentials"; +import { TVAddIcon } from "./TVAddIcon"; +import { TVServerIcon } from "./TVServerIcon"; + +interface TVServerSelectionScreenProps { + onServerSelect: (server: SavedServer) => void; + onAddServer: () => void; + onDeleteServer: (server: SavedServer) => void; + disabled?: boolean; +} + +export const TVServerSelectionScreen: React.FC< + TVServerSelectionScreenProps +> = ({ onServerSelect, onAddServer, onDeleteServer, disabled = false }) => { + const typography = useScaledTVTypography(); + const [_previousServers] = useMMKVString("previousServers"); + + const previousServers = useMemo(() => { + try { + return JSON.parse(_previousServers || "[]") as SavedServer[]; + } catch { + return []; + } + }, [_previousServers]); + + const hasServers = previousServers.length > 0; + + const handleDeleteServer = (server: SavedServer) => { + Alert.alert( + t("server.remove_server"), + t("server.remove_server_description", { + server: server.name || server.address, + }), + [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.delete"), + style: "destructive", + onPress: () => onDeleteServer(server), + }, + ], + ); + }; + + return ( + + + {/* Logo */} + + + + + {/* Title */} + + Streamyfin + + + {hasServers + ? t("server.select_your_server") + : t("server.add_server_to_get_started")} + + + {/* Server Icons Grid */} + + {previousServers.map((server, index) => ( + onServerSelect(server)} + onLongPress={() => handleDeleteServer(server)} + hasTVPreferredFocus={index === 0} + disabled={disabled} + /> + ))} + + {/* Add Server Button */} + + + + + ); +}; diff --git a/components/login/TVUserIcon.tsx b/components/login/TVUserIcon.tsx new file mode 100644 index 00000000..cbfd67cf --- /dev/null +++ b/components/login/TVUserIcon.tsx @@ -0,0 +1,127 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import type { AccountSecurityType } from "@/utils/secureCredentials"; + +export interface TVUserIconProps { + username: string; + securityType: AccountSecurityType; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVUserIcon = React.forwardRef( + ( + { username, securityType, onPress, hasTVPreferredFocus, disabled = false }, + ref, + ) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => { + switch (securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + const hasSecurityProtection = securityType !== "none"; + + return ( + + + + + + + + {/* Security badge */} + {hasSecurityProtection && ( + + + + )} + + + + {username} + + + + ); + }, +); diff --git a/components/login/TVUserSelectionScreen.tsx b/components/login/TVUserSelectionScreen.tsx new file mode 100644 index 00000000..2c1a0c3a --- /dev/null +++ b/components/login/TVUserSelectionScreen.tsx @@ -0,0 +1,130 @@ +import { t } from "i18next"; +import React from "react"; +import { ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { TVAddIcon } from "./TVAddIcon"; +import { TVBackIcon } from "./TVBackIcon"; +import { TVUserIcon } from "./TVUserIcon"; + +interface TVUserSelectionScreenProps { + server: SavedServer; + onUserSelect: (account: SavedServerAccount) => void; + onAddUser: () => void; + onChangeServer: () => void; + disabled?: boolean; +} + +export const TVUserSelectionScreen: React.FC = ({ + server, + onUserSelect, + onAddUser, + onChangeServer, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + + const accounts = server.accounts || []; + const hasAccounts = accounts.length > 0; + + return ( + + + {/* Server Info Header */} + + + {server.name || server.address} + + {server.name && ( + + {server.address.replace(/^https?:\/\//, "")} + + )} + + {hasAccounts + ? t("login.select_user") + : t("login.add_user_to_login")} + + + + {/* User Icons Grid with Back and Add buttons */} + + {/* Back/Change Server Button (left) */} + + + {/* User Icons */} + {accounts.map((account, index) => ( + onUserSelect(account)} + hasTVPreferredFocus={index === 0} + disabled={disabled} + /> + ))} + + {/* Add User Button (right) */} + + + + + ); +}; diff --git a/docs/tv-modal-guide.md b/docs/tv-modal-guide.md new file mode 100644 index 00000000..a1b57e9b --- /dev/null +++ b/docs/tv-modal-guide.md @@ -0,0 +1,416 @@ +# TV Modal Guide + +This document explains how to implement modals, bottom sheets, and overlays on Apple TV and Android TV in React Native. + +## The Problem + +On TV platforms, modals have unique challenges: +- The hardware back button must work correctly to dismiss modals +- Focus management must be handled explicitly +- React Native's `Modal` component breaks the TV focus chain +- Overlay/absolute-positioned modals don't handle back button correctly + +## Navigation-Based Modal Pattern (Recommended) + +For modals that need proper back button support, use the **navigation-based modal pattern**. This leverages Expo Router's stack navigation with transparent modal presentation. + +### Architecture + +``` +┌─────────────────────────────────────┐ +│ 1. Jotai Atom (state) │ +│ Stores modal data/params │ +├─────────────────────────────────────┤ +│ 2. Hook (trigger) │ +│ Sets atom + calls router.push() │ +├─────────────────────────────────────┤ +│ 3. Page File (UI) │ +│ Reads atom, renders modal │ +│ Clears atom on unmount │ +├─────────────────────────────────────┤ +│ 4. Stack.Screen (config) │ +│ presentation: transparentModal │ +│ animation: fade │ +└─────────────────────────────────────┘ +``` + +### Step 1: Create the Atom + +Create a Jotai atom to store the modal state/data: + +```typescript +// utils/atoms/tvExampleModal.ts +import { atom } from "jotai"; + +export interface TVExampleModalData { + itemId: string; + title: string; + // ... other data the modal needs +} + +export const tvExampleModalAtom = atom(null); +``` + +### Step 2: Create the Hook + +Create a hook that sets the atom and navigates to the modal: + +```typescript +// hooks/useTVExampleModal.ts +import { useSetAtom } from "jotai"; +import { router } from "expo-router"; +import { tvExampleModalAtom, TVExampleModalData } from "@/utils/atoms/tvExampleModal"; + +export const useTVExampleModal = () => { + const setModalData = useSetAtom(tvExampleModalAtom); + + const openModal = (data: TVExampleModalData) => { + setModalData(data); + router.push("/tv-example-modal"); + }; + + return { openModal }; +}; +``` + +### Step 3: Create the Modal Page + +Create a page file that reads the atom and renders the modal UI: + +```typescript +// app/(auth)/tv-example-modal.tsx +import { useEffect } from "react"; +import { View, Pressable, Text } from "react-native"; +import { useAtom } from "jotai"; +import { router } from "expo-router"; +import { BlurView } from "expo-blur"; +import { tvExampleModalAtom } from "@/utils/atoms/tvExampleModal"; + +export default function TVExampleModal() { + const [modalData, setModalData] = useAtom(tvExampleModalAtom); + + // Clear atom on unmount + useEffect(() => { + return () => { + setModalData(null); + }; + }, [setModalData]); + + // Handle case where modal is opened without data + if (!modalData) { + router.back(); + return null; + } + + return ( + + {/* Background overlay */} + router.back()} + /> + + {/* Modal content */} + + + {modalData.title} + + {/* Modal content here */} + + router.back()} + hasTVPreferredFocus + style={({ focused }) => ({ + marginTop: 24, + padding: 16, + borderRadius: 8, + backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)", + })} + > + {({ focused }) => ( + + Close + + )} + + + + ); +} +``` + +### Step 4: Add Stack.Screen Configuration + +Add the modal route to `app/_layout.tsx`: + +```typescript +// In app/_layout.tsx, inside your Stack navigator + +``` + +### Usage + +```typescript +// In any component +import { useTVExampleModal } from "@/hooks/useTVExampleModal"; + +const MyComponent = () => { + const { openModal } = useTVExampleModal(); + + return ( + openModal({ itemId: "123", title: "Example" })} + > + Open Modal + + ); +}; +``` + +### Reference Implementation + +See `useTVRequestModal` + `app/(auth)/tv-request-modal.tsx` for a complete working example. + +--- + +## Bottom Sheet Pattern (Inline Overlays) + +For simpler overlays that don't need back button navigation (like option selectors), use an **inline absolute-positioned overlay**. This pattern is ideal for: +- Dropdown selectors +- Quick action menus +- Option pickers + +### Key Principles + +1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain +2. **Horizontal ScrollView for options** - Natural for TV remotes (left/right D-pad) +3. **Disable background focus** - Prevent focus flickering between overlay and background + +### Implementation + +```typescript +import { useState } from "react"; +import { View, ScrollView, Pressable, Text } from "react-native"; +import { BlurView } from "expo-blur"; + +const TVOptionSelector: React.FC<{ + options: { label: string; value: string }[]; + selectedValue: string; + onSelect: (value: string) => void; + isOpen: boolean; + onClose: () => void; +}> = ({ options, selectedValue, onSelect, isOpen, onClose }) => { + if (!isOpen) return null; + + const selectedIndex = options.findIndex(o => o.value === selectedValue); + + return ( + + + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + + + + ); +}; +``` + +### Option Card Component + +```typescript +import { useState, useRef, useEffect } from "react"; +import { Pressable, Text, Animated } from "react-native"; + +const TVOptionCard: React.FC<{ + label: string; + isSelected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +}> = ({ label, isSelected, hasTVPreferredFocus, onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (toValue: number) => { + Animated.spring(scale, { + toValue, + useNativeDriver: true, + tension: 50, + friction: 7, + }).start(); + }; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {label} + + + + ); +}; +``` + +### Reference Implementation + +See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`. + +--- + +## Focus Management for Overlays + +**CRITICAL**: When displaying overlays on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop. + +### Solution + +Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible: + +```typescript +// 1. Track modal state +const [openModal, setOpenModal] = useState(null); +const isModalOpen = openModal !== null; + +// 2. Each focusable component accepts disabled prop +const TVFocusableButton: React.FC<{ + onPress: () => void; + disabled?: boolean; +}> = ({ onPress, disabled }) => ( + + {/* content */} + +); + +// 3. Pass disabled to all background components when modal is open + +``` + +### Reference Implementation + +See `settings.tv.tsx` for a complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc. + +--- + +## Focus Trapping + +For modals that should trap focus (prevent navigation outside the modal), use `TVFocusGuideView` with trap props: + +```typescript +import { TVFocusGuideView } from "react-native"; + + + {/* Modal content - focus cannot escape */} + +``` + +**Warning**: Don't use `autoFocus` on focus guide wrappers when you also have bidirectional focus guides - it can interfere with navigation. + +--- + +## Common Mistakes + +| Mistake | Result | Fix | +|---------|--------|-----| +| Using React Native `Modal` | Focus chain breaks | Use navigation-based or absolute positioning | +| Overlay without disabling background focus | Focus flickering loop | Add `disabled` prop to all background focusables | +| No `hasTVPreferredFocus` in modal | Focus stuck on background | Set preferred focus on first modal element | +| Missing `presentation: "transparentModal"` | Modal not transparent | Add to Stack.Screen options | +| Not clearing atom on unmount | Stale data on reopen | Clear in useEffect cleanup | + +--- + +## When to Use Which Pattern + +| Scenario | Pattern | +|----------|---------| +| Full-screen modal with back button | Navigation-based modal | +| Confirmation dialogs | Navigation-based modal | +| Option selectors / dropdowns | Bottom sheet (inline) | +| Quick action menus | Bottom sheet (inline) | +| Complex forms | Navigation-based modal | diff --git a/hooks/useTVAccountActionModal.ts b/hooks/useTVAccountActionModal.ts new file mode 100644 index 00000000..97db7ac5 --- /dev/null +++ b/hooks/useTVAccountActionModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface ShowAccountActionModalParams { + server: SavedServer; + account: SavedServerAccount; + onLogin: () => void; + onDelete: () => void; +} + +export const useTVAccountActionModal = () => { + const router = useRouter(); + + const showAccountActionModal = useCallback( + (params: ShowAccountActionModalParams) => { + store.set(tvAccountActionModalAtom, { + server: params.server, + account: params.account, + onLogin: params.onLogin, + onDelete: params.onDelete, + }); + router.push("/tv-account-action-modal"); + }, + [router], + ); + + return { showAccountActionModal }; +}; diff --git a/hooks/useTVAccountSelectModal.ts b/hooks/useTVAccountSelectModal.ts index 3be0f5d6..3bc61ed7 100644 --- a/hooks/useTVAccountSelectModal.ts +++ b/hooks/useTVAccountSelectModal.ts @@ -9,9 +9,9 @@ import { store } from "@/utils/store"; interface ShowAccountSelectModalParams { server: SavedServer; - onAccountSelect: (account: SavedServerAccount) => void; + onAccountAction: (account: SavedServerAccount) => void; onAddAccount: () => void; - onDeleteAccount: (account: SavedServerAccount) => void; + onDeleteServer: () => void; } export const useTVAccountSelectModal = () => { @@ -21,9 +21,9 @@ export const useTVAccountSelectModal = () => { (params: ShowAccountSelectModalParams) => { store.set(tvAccountSelectModalAtom, { server: params.server, - onAccountSelect: params.onAccountSelect, + onAccountAction: params.onAccountAction, onAddAccount: params.onAddAccount, - onDeleteAccount: params.onDeleteAccount, + onDeleteServer: params.onDeleteServer, }); router.push("/tv-account-select-modal"); }, diff --git a/hooks/useTVServerActionModal.ts b/hooks/useTVServerActionModal.ts deleted file mode 100644 index f0da43f1..00000000 --- a/hooks/useTVServerActionModal.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/translations/en.json b/translations/en.json index 651a1aa3..bd25ba87 100644 --- a/translations/en.json +++ b/translations/en.json @@ -4,6 +4,9 @@ "error_title": "Error", "login_title": "Log In", "login_to_title": "Log in to", + "select_user": "Select a user to log in", + "add_user_to_login": "Add a user to log in", + "add_user": "Add User", "username_placeholder": "Username", "password_placeholder": "Password", "login_button": "Log In", @@ -44,7 +47,11 @@ "add_account": "Add Account", "remove_account_description": "This will remove the saved credentials for {{username}}.", "remove_server": "Remove Server", - "remove_server_description": "This will remove {{server}} and all saved accounts from your list." + "remove_server_description": "This will remove {{server}} and all saved accounts from your list.", + "select_your_server": "Select Your Server", + "add_server_to_get_started": "Add a server to get started", + "add_server": "Add Server", + "change_server": "Change Server" }, "save_account": { "title": "Save Account", @@ -115,7 +122,7 @@ "switch_user": { "title": "Switch User", "account": "Account", - "switch_user": "Switch User", + "switch_user": "Switch User on This Server", "current": "current" }, "categories": { diff --git a/utils/atoms/selectedTVServer.ts b/utils/atoms/selectedTVServer.ts new file mode 100644 index 00000000..c5d25479 --- /dev/null +++ b/utils/atoms/selectedTVServer.ts @@ -0,0 +1,60 @@ +import { atom } from "jotai"; +import { storage } from "../mmkv"; + +const STORAGE_KEY = "selectedTVServer"; + +export interface SelectedTVServerState { + address: string; + name?: string; +} + +/** + * Load the selected TV server from MMKV storage. + */ +function loadSelectedTVServer(): SelectedTVServerState | null { + const stored = storage.getString(STORAGE_KEY); + if (stored) { + try { + return JSON.parse(stored) as SelectedTVServerState; + } catch { + return null; + } + } + return null; +} + +/** + * Save the selected TV server to MMKV storage. + */ +function saveSelectedTVServer(server: SelectedTVServerState | null): void { + if (server) { + storage.set(STORAGE_KEY, JSON.stringify(server)); + } else { + storage.remove(STORAGE_KEY); + } +} + +/** + * Base atom holding the selected TV server state. + */ +const baseSelectedTVServerAtom = atom( + loadSelectedTVServer(), +); + +/** + * Derived atom that persists changes to MMKV storage. + */ +export const selectedTVServerAtom = atom( + (get) => get(baseSelectedTVServerAtom), + (_get, set, newValue: SelectedTVServerState | null) => { + saveSelectedTVServer(newValue); + set(baseSelectedTVServerAtom, newValue); + }, +); + +/** + * Clear the selected TV server (used when changing servers). + */ +export function clearSelectedTVServer(): void { + storage.remove(STORAGE_KEY); +} diff --git a/utils/atoms/tvAccountActionModal.ts b/utils/atoms/tvAccountActionModal.ts new file mode 100644 index 00000000..c9532a7f --- /dev/null +++ b/utils/atoms/tvAccountActionModal.ts @@ -0,0 +1,14 @@ +import { atom } from "jotai"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; + +export type TVAccountActionModalState = { + server: SavedServer; + account: SavedServerAccount; + onLogin: () => void; + onDelete: () => void; +} | null; + +export const tvAccountActionModalAtom = atom(null); diff --git a/utils/atoms/tvAccountSelectModal.ts b/utils/atoms/tvAccountSelectModal.ts index 3cafa61e..9fd8bf20 100644 --- a/utils/atoms/tvAccountSelectModal.ts +++ b/utils/atoms/tvAccountSelectModal.ts @@ -6,9 +6,9 @@ import type { export type TVAccountSelectModalState = { server: SavedServer; - onAccountSelect: (account: SavedServerAccount) => void; + onAccountAction: (account: SavedServerAccount) => void; onAddAccount: () => void; - onDeleteAccount: (account: SavedServerAccount) => void; + onDeleteServer: () => void; } | null; export const tvAccountSelectModalAtom = atom(null); diff --git a/utils/atoms/tvServerActionModal.ts b/utils/atoms/tvServerActionModal.ts deleted file mode 100644 index 38d99e83..00000000 --- a/utils/atoms/tvServerActionModal.ts +++ /dev/null @@ -1,10 +0,0 @@ -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);