mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-01 07:58:08 +00:00
refactor: login page
This commit is contained in:
95
CLAUDE.md
95
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
|
||||
<View style={{
|
||||
position: "absolute",
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
}}>
|
||||
<BlurView intensity={80} tint="dark" style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24 }}>
|
||||
{/* Content */}
|
||||
</BlurView>
|
||||
</View>
|
||||
```
|
||||
|
||||
2. **Horizontal ScrollView with focusable cards**:
|
||||
```typescript
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{ paddingHorizontal: 48, paddingVertical: 10, gap: 12 }}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={index}
|
||||
hasTVPreferredFocus={index === selectedIndex}
|
||||
onPress={() => { onSelect(option.value); onClose(); }}
|
||||
// ...
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
```
|
||||
|
||||
3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`:
|
||||
```typescript
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => { setFocused(true); animateTo(1.05); }}
|
||||
onBlur={() => { setFocused(false); animateTo(1); }}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale }], backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)" }}>
|
||||
<Text style={{ color: focused ? "#000" : "#fff" }}>{label}</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
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<ModalType | null>(null);
|
||||
const isModalOpen = openModal !== null;
|
||||
|
||||
// 2. Each focusable component accepts disabled prop
|
||||
const TVFocusableButton: React.FC<{
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
}> = ({ onPress, disabled }) => (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
hasTVPreferredFocus={isFirst && !disabled}
|
||||
>
|
||||
{/* content */}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
// 3. Pass disabled to all background components when modal is open
|
||||
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
|
||||
```
|
||||
|
||||
**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)
|
||||
|
||||
|
||||
@@ -473,7 +473,7 @@ function Layout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='tv-server-action-modal'
|
||||
name='tv-account-action-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal";
|
||||
import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
// Action card component
|
||||
const TVServerActionCard: React.FC<{
|
||||
const TVAccountActionCard: React.FC<{
|
||||
label: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
variant?: "default" | "destructive";
|
||||
@@ -54,8 +54,8 @@ const TVServerActionCard: React.FC<{
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
width: 180,
|
||||
height: 90,
|
||||
flexDirection: "row",
|
||||
height: 60,
|
||||
backgroundColor: focused
|
||||
? isDestructive
|
||||
? "#ef4444"
|
||||
@@ -66,13 +66,13 @@ const TVServerActionCard: React.FC<{
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
gap: 8,
|
||||
paddingHorizontal: 24,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={28}
|
||||
size={22}
|
||||
color={
|
||||
focused
|
||||
? isDestructive
|
||||
@@ -94,7 +94,6 @@ const TVServerActionCard: React.FC<{
|
||||
? "#ef4444"
|
||||
: "#fff",
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -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 */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 4,
|
||||
paddingHorizontal: 48,
|
||||
}}
|
||||
>
|
||||
{modalState.account.username}
|
||||
</Text>
|
||||
|
||||
{/* Server name as subtitle */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 8,
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{modalState.server.name || modalState.server.address}
|
||||
@@ -223,23 +229,18 @@ export default function TVServerActionModalPage() {
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<TVServerActionCard
|
||||
<TVAccountActionCard
|
||||
label={t("common.login")}
|
||||
icon='log-in-outline'
|
||||
hasTVPreferredFocus
|
||||
onPress={handleLogin}
|
||||
/>
|
||||
<TVServerActionCard
|
||||
<TVAccountActionCard
|
||||
label={t("common.delete")}
|
||||
icon='trash-outline'
|
||||
variant='destructive'
|
||||
onPress={handleDelete}
|
||||
/>
|
||||
<TVServerActionCard
|
||||
label={t("common.cancel")}
|
||||
icon='close-outline'
|
||||
onPress={handleClose}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
backgroundColor: focused
|
||||
? isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff"
|
||||
: isDestructive
|
||||
? "rgba(239, 68, 68, 0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 14,
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
minHeight: 72,
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={22}
|
||||
color={
|
||||
focused
|
||||
? isDestructive
|
||||
? "#fff"
|
||||
: "#000"
|
||||
: isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff"
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: focused
|
||||
? isDestructive
|
||||
? "#fff"
|
||||
: "#000"
|
||||
: isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
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() {
|
||||
<Animated.View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 80,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
opacity: overlayOpacity,
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: contentScale }],
|
||||
width: "100%",
|
||||
maxWidth: 700,
|
||||
transform: [{ translateY: sheetTranslateY }],
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
intensity={80}
|
||||
tint='dark'
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
@@ -89,67 +175,78 @@ export default function TVAccountSelectModalPage() {
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={{
|
||||
padding: 40,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
marginBottom: 4,
|
||||
paddingHorizontal: 48,
|
||||
}}
|
||||
>
|
||||
{t("server.select_account")}
|
||||
</Text>
|
||||
|
||||
{/* Server name as subtitle */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 32,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
}}
|
||||
>
|
||||
{modalState.server.name || modalState.server.address}
|
||||
</Text>
|
||||
|
||||
{/* All options in single horizontal row */}
|
||||
{isReady && (
|
||||
<>
|
||||
<View style={{ gap: 12, marginBottom: 24 }}>
|
||||
{modalState.server.accounts?.map((account, index) => (
|
||||
<TVAccountCard
|
||||
key={account.userId}
|
||||
account={account}
|
||||
onPress={() => {
|
||||
modalState.onAccountSelect(account);
|
||||
router.back();
|
||||
}}
|
||||
onLongPress={() => {
|
||||
modalState.onDeleteAccount(account);
|
||||
}}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={{ gap: 12 }}>
|
||||
<Button
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 20,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{modalState.server.accounts?.map((account, index) => (
|
||||
<TVUserCard
|
||||
key={account.userId}
|
||||
username={account.username}
|
||||
securityType={account.securityType}
|
||||
onPress={() => {
|
||||
modalState.onAddAccount();
|
||||
router.back();
|
||||
modalState.onAccountAction(account);
|
||||
}}
|
||||
color='white'
|
||||
>
|
||||
{t("server.add_account")}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={handleClose}
|
||||
color='black'
|
||||
className='bg-neutral-800'
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
hasTVPreferredFocus={index === 0}
|
||||
/>
|
||||
))}
|
||||
<TVAccountSelectAction
|
||||
label={t("server.add_account")}
|
||||
icon='person-add-outline'
|
||||
onPress={() => {
|
||||
modalState.onAddAccount();
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
<TVAccountSelectAction
|
||||
label={t("server.remove_server")}
|
||||
icon='trash-outline'
|
||||
variant='destructive'
|
||||
onPress={() => {
|
||||
modalState.onDeleteServer();
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
|
||||
@@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
||||
{/* Password Input */}
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
||||
<Text className='text-neutral-400 text-sm mb-2'>
|
||||
{t("login.password")}
|
||||
{t("login.password_placeholder")}
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
value={password}
|
||||
@@ -136,7 +136,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
||||
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<PasswordEntryModalProps> = ({
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
) : (
|
||||
t("login.login")
|
||||
t("common.login")
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
82
components/login/TVAddIcon.tsx
Normal file
82
components/login/TVAddIcon.tsx
Normal file
@@ -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<View, TVAddIconProps>(
|
||||
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
alignItems: "center",
|
||||
width: 160,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 16 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.15)"
|
||||
: "rgba(255,255,255,0.05)",
|
||||
marginBottom: 14,
|
||||
borderWidth: 2,
|
||||
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
|
||||
borderStyle: "dashed",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='add'
|
||||
size={56}
|
||||
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
162
components/login/TVAddServerForm.tsx
Normal file
162
components/login/TVAddServerForm.tsx
Normal file
@@ -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<void>;
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
animateFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
}}
|
||||
style={{ alignSelf: "flex-start", marginBottom: 24 }}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={28}
|
||||
color={isFocused ? "#000" : "#fff"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: isFocused ? "#000" : "#fff",
|
||||
fontSize: 20,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
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 (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Back Button */}
|
||||
<TVBackButton
|
||||
onPress={onBack}
|
||||
label={t("common.back")}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
{/* Server URL Input */}
|
||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
value={serverURL}
|
||||
onChangeText={setServerURL}
|
||||
keyboardType='url'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
returnKeyType='done'
|
||||
hasTVPreferredFocus
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Connect Button */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Button
|
||||
onPress={handleConnect}
|
||||
loading={loading}
|
||||
disabled={loading || !serverURL.trim()}
|
||||
color='white'
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Hint text */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#6B7280",
|
||||
textAlign: "left",
|
||||
paddingHorizontal: 8,
|
||||
}}
|
||||
>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
230
components/login/TVAddUserForm.tsx
Normal file
230
components/login/TVAddUserForm.tsx
Normal file
@@ -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<void>;
|
||||
onQuickConnect: () => Promise<void>;
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
animateFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
}}
|
||||
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={28}
|
||||
color={isFocused ? "#000" : "#fff"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: isFocused ? "#000" : "#fff",
|
||||
fontSize: 20,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
|
||||
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 (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Back Button */}
|
||||
<TVBackButton
|
||||
onPress={onBack}
|
||||
label={t("common.back")}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.title,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{serverName ? (
|
||||
<>
|
||||
{`${t("login.login_to_title")} `}
|
||||
<Text style={{ color: "#fff" }}>{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
{serverAddress}
|
||||
</Text>
|
||||
|
||||
{/* Username Input */}
|
||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("login.username_placeholder")}
|
||||
value={credentials.username}
|
||||
onChangeText={(text) =>
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='username'
|
||||
returnKeyType='next'
|
||||
hasTVPreferredFocus
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("login.password_placeholder")}
|
||||
value={credentials.password}
|
||||
onChangeText={(text) =>
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
}
|
||||
secureTextEntry
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
returnKeyType='done'
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Save Account Toggle */}
|
||||
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
||||
<TVSaveAccountToggle
|
||||
value={saveAccount}
|
||||
onValueChange={setSaveAccount}
|
||||
label={t("save_account.save_for_later")}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Login Button */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={!credentials.username.trim() || loading}
|
||||
color='white'
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Quick Connect Button */}
|
||||
<Button
|
||||
onPress={onQuickConnect}
|
||||
color='black'
|
||||
className='bg-neutral-800 border border-neutral-700'
|
||||
>
|
||||
{t("login.quick_connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
82
components/login/TVBackIcon.tsx
Normal file
82
components/login/TVBackIcon.tsx
Normal file
@@ -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<View, TVBackIconProps>(
|
||||
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
alignItems: "center",
|
||||
width: 160,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 16 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.15)"
|
||||
: "rgba(255,255,255,0.05)",
|
||||
marginBottom: 14,
|
||||
borderWidth: 2,
|
||||
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
|
||||
borderStyle: "dashed",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='arrow-back'
|
||||
size={56}
|
||||
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
animateFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
}}
|
||||
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={28}
|
||||
color={isFocused ? "#000" : "#fff"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: isFocused ? "#000" : "#fff",
|
||||
fontSize: 20,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
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<TVLoginScreen>("server-selection");
|
||||
|
||||
// Current selected server for user selection screen
|
||||
const [currentServer, setCurrentServer] = useState<SavedServer | null>(null);
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
|
||||
// Loading states
|
||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
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<SavedServer | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedAccount, setSelectedAccount] =
|
||||
useState<SavedServerAccount | null>(null);
|
||||
|
||||
// Server login trigger state
|
||||
const [loginTriggerServer, setLoginTriggerServer] =
|
||||
useState<SavedServer | null>(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 (
|
||||
<TVAddUserForm
|
||||
serverName={serverName}
|
||||
serverAddress={api.basePath}
|
||||
onLogin={handleLogin}
|
||||
onQuickConnect={handleQuickConnect}
|
||||
onBack={handleChangeServer}
|
||||
loading={loading}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentScreen) {
|
||||
case "server-selection":
|
||||
return (
|
||||
<TVServerSelectionScreen
|
||||
onServerSelect={handleServerSelect}
|
||||
onAddServer={() => setCurrentScreen("add-server")}
|
||||
onDeleteServer={handleDeleteServer}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
case "user-selection":
|
||||
if (!currentServer) {
|
||||
setCurrentScreen("server-selection");
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TVUserSelectionScreen
|
||||
server={currentServer}
|
||||
onUserSelect={handleUserSelect}
|
||||
onAddUser={() => {
|
||||
// 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 (
|
||||
<TVAddServerForm
|
||||
onConnect={handleConnect}
|
||||
onBack={() => setCurrentScreen("server-selection")}
|
||||
loading={loadingServerCheck}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
case "add-user":
|
||||
return (
|
||||
<TVAddUserForm
|
||||
serverName={serverName}
|
||||
serverAddress={currentServer?.address || api?.basePath || ""}
|
||||
onLogin={handleLogin}
|
||||
onQuickConnect={handleQuickConnect}
|
||||
onBack={() => {
|
||||
removeServer();
|
||||
setCurrentScreen("user-selection");
|
||||
}}
|
||||
loading={loading}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
{api?.basePath ? (
|
||||
// ==================== CREDENTIALS SCREEN ====================
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Back Button */}
|
||||
<TVBackButton
|
||||
onPress={() => removeServer()}
|
||||
label={t("login.change_server")}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 48,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{serverName ? (
|
||||
<>
|
||||
{`${t("login.login_to_title")} `}
|
||||
<Text style={{ color: "#fff" }}>{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
{api.basePath}
|
||||
</Text>
|
||||
|
||||
{/* Username Input - extra padding for focus scale */}
|
||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("login.username_placeholder")}
|
||||
value={credentials.username}
|
||||
onChangeText={(text) =>
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='username'
|
||||
returnKeyType='next'
|
||||
hasTVPreferredFocus
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("login.password_placeholder")}
|
||||
value={credentials.password}
|
||||
onChangeText={(text) =>
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
}
|
||||
secureTextEntry
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
returnKeyType='done'
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Save Account Toggle */}
|
||||
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
||||
<TVSaveAccountToggle
|
||||
value={saveAccount}
|
||||
onValueChange={setSaveAccount}
|
||||
label={t("save_account.save_for_later")}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Login Button */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={!credentials.username.trim() || loading}
|
||||
color='white'
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Quick Connect Button */}
|
||||
<Button
|
||||
onPress={handleQuickConnect}
|
||||
color='black'
|
||||
className='bg-neutral-800 border border-neutral-700'
|
||||
>
|
||||
{t("login.quick_connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
// ==================== SERVER SELECTION SCREEN ====================
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<View style={{ alignItems: "center", marginBottom: 16 }}>
|
||||
<Image
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
style={{ width: 150, height: 150 }}
|
||||
contentFit='contain'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 48,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Streamyfin
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: "#9CA3AF",
|
||||
textAlign: "center",
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
|
||||
{/* Server URL Input - extra padding for focus scale */}
|
||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
value={serverURL}
|
||||
onChangeText={setServerURL}
|
||||
keyboardType='url'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
returnKeyType='done'
|
||||
hasTVPreferredFocus
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Connect Button */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Button
|
||||
onPress={() => handleConnect(serverURL)}
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck || !serverURL.trim()}
|
||||
color='white'
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Previous Servers */}
|
||||
<View style={{ paddingHorizontal: 8 }}>
|
||||
<TVPreviousServersList
|
||||
onServerSelect={(s) => handleConnect(s.address)}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
onPinRequired={handlePinRequired}
|
||||
onPasswordRequired={handlePasswordRequired}
|
||||
onServerAction={handleServerAction}
|
||||
loginServerOverride={loginTriggerServer}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>{renderScreen()}</View>
|
||||
|
||||
{/* Save Account Modal */}
|
||||
<TVSaveAccountModal
|
||||
@@ -656,7 +502,7 @@ export const TVLogin: React.FC = () => {
|
||||
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 || ""}
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.1);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
styles.numberButton,
|
||||
{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: focused
|
||||
? "rgba(255, 255, 255, 0.15)"
|
||||
: "transparent",
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{isBackspace ? (
|
||||
<Ionicons
|
||||
name='backspace-outline'
|
||||
size={28}
|
||||
color={focused ? "#000" : "#fff"}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[styles.numberText, { color: focused ? "#000" : "#fff" }]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// PIN dot indicator
|
||||
const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({
|
||||
filled,
|
||||
error,
|
||||
}) => (
|
||||
<View
|
||||
style={[
|
||||
styles.pinDot,
|
||||
filled && styles.pinDotFilled,
|
||||
error && styles.pinDotError,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: focused ? "rgba(255,255,255,0.15)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.6)",
|
||||
fontWeight: "500",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@@ -80,23 +161,21 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [pinCode, setPinCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const pinInputRef = useRef<TVPinInputRef>(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<TVPINEntryModalProps> = ({
|
||||
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<TVPINEntryModalProps> = ({
|
||||
]).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<TVPINEntryModalProps> = ({
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
styles.contentContainer,
|
||||
{ transform: [{ scale: contentScale }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<BlurView intensity={60} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
@@ -218,44 +293,103 @@ export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{t("pin.enter_pin_for", { username })}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
|
||||
<Text style={styles.subtitle}>{username}</Text>
|
||||
|
||||
{/* PIN Input */}
|
||||
{/* PIN Dots */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pinDotsContainer,
|
||||
{ transform: [{ translateX: shakeAnimation }] },
|
||||
]}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<PinDot key={i} filled={pinCode.length > i} error={error} />
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
{/* Number Pad */}
|
||||
{isReady && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pinContainer,
|
||||
{ transform: [{ translateX: shakeAnimation }] },
|
||||
]}
|
||||
>
|
||||
<TVPinInput
|
||||
ref={pinInputRef}
|
||||
value={pinCode}
|
||||
onChangeText={handlePinChange}
|
||||
length={4}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
{isVerifying && (
|
||||
<Text style={styles.verifyingText}>
|
||||
{t("common.verifying")}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
<View style={styles.numberPad}>
|
||||
{/* Row 1: 1-3 */}
|
||||
<View style={styles.numberRow}>
|
||||
<NumberPadButton
|
||||
value='1'
|
||||
onPress={() => handleNumberPress("1")}
|
||||
hasTVPreferredFocus
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
<NumberPadButton
|
||||
value='2'
|
||||
onPress={() => handleNumberPress("2")}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
<NumberPadButton
|
||||
value='3'
|
||||
onPress={() => handleNumberPress("3")}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
</View>
|
||||
{/* Row 2: 4-6 */}
|
||||
<View style={styles.numberRow}>
|
||||
<NumberPadButton
|
||||
value='4'
|
||||
onPress={() => handleNumberPress("4")}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
<NumberPadButton
|
||||
value='5'
|
||||
onPress={() => handleNumberPress("5")}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
<NumberPadButton
|
||||
value='6'
|
||||
onPress={() => handleNumberPress("6")}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
</View>
|
||||
{/* Row 3: 7-9 */}
|
||||
<View style={styles.numberRow}>
|
||||
<NumberPadButton
|
||||
value='7'
|
||||
onPress={() => handleNumberPress("7")}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
<NumberPadButton
|
||||
value='8'
|
||||
onPress={() => handleNumberPress("8")}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
<NumberPadButton
|
||||
value='9'
|
||||
onPress={() => handleNumberPress("9")}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
</View>
|
||||
{/* Row 4: empty, 0, backspace */}
|
||||
<View style={styles.numberRow}>
|
||||
<View style={styles.numberButtonPlaceholder} />
|
||||
<NumberPadButton
|
||||
value='0'
|
||||
onPress={() => handleNumberPress("0")}
|
||||
disabled={isVerifying}
|
||||
/>
|
||||
<NumberPadButton
|
||||
value=''
|
||||
onPress={handleBackspace}
|
||||
isBackspace
|
||||
disabled={isVerifying || pinCode.length === 0}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Forgot PIN */}
|
||||
{isReady && onForgotPIN && (
|
||||
<View style={styles.forgotContainer}>
|
||||
<TVForgotPINButton
|
||||
<ForgotPINLink
|
||||
onPress={handleForgotPIN}
|
||||
label={t("pin.forgot_pin")}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -249,14 +249,16 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||
{/* Password Input */}
|
||||
{isReady && (
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>{t("login.password")}</Text>
|
||||
<Text style={styles.inputLabel}>
|
||||
{t("login.password_placeholder")}
|
||||
</Text>
|
||||
<TVPasswordInput
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("login.password")}
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onSubmitEditing={handleSubmit}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
|
||||
@@ -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<void>;
|
||||
onPasswordLogin?: (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<void>;
|
||||
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<TVPreviousServersListProps> = ({
|
||||
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<string | null>(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 (
|
||||
<View style={{ marginTop: 32 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("server.previous_servers")}
|
||||
</Text>
|
||||
|
||||
<View style={{ gap: 12 }}>
|
||||
{previousServers.map((server) => (
|
||||
<TVServerCard
|
||||
key={server.address}
|
||||
title={server.name || server.address}
|
||||
subtitle={getServerSubtitle(server)}
|
||||
securityIcon={getSecurityIcon(server)}
|
||||
isLoading={loadingServer === server.address}
|
||||
onPress={() => handleServerPress(server)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -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<TVServerCardProps> = ({
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isDisabled}
|
||||
focusable={!isDisabled}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !isDisabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
transform: [{ scale }],
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
{ shadowOpacity: glowOpacity },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
||||
borderWidth: 2,
|
||||
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 20,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ marginLeft: 16 }}>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size='small' color='#fff' />
|
||||
) : securityIcon ? (
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<Ionicons
|
||||
name={securityIcon}
|
||||
size={20}
|
||||
color='#fff'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Ionicons
|
||||
name='chevron-forward'
|
||||
size={24}
|
||||
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Ionicons
|
||||
name='chevron-forward'
|
||||
size={24}
|
||||
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
118
components/login/TVServerIcon.tsx
Normal file
118
components/login/TVServerIcon.tsx
Normal file
@@ -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<View, TVServerIconProps>(
|
||||
(
|
||||
{
|
||||
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 (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
alignItems: "center",
|
||||
width: 160,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 16 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
overflow: "hidden",
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
marginBottom: 14,
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 48,
|
||||
fontWeight: "bold",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
textAlign: "center",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
|
||||
{name && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.8)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{address.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
137
components/login/TVServerSelectionScreen.tsx
Normal file
137
components/login/TVServerSelectionScreen.tsx
Normal file
@@ -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 (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<View style={{ alignItems: "center", marginBottom: 16 }}>
|
||||
<Image
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
style={{ width: 150, height: 150 }}
|
||||
contentFit='contain'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.title,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Streamyfin
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "#9CA3AF",
|
||||
textAlign: "center",
|
||||
marginBottom: 48,
|
||||
}}
|
||||
>
|
||||
{hasServers
|
||||
? t("server.select_your_server")
|
||||
: t("server.add_server_to_get_started")}
|
||||
</Text>
|
||||
|
||||
{/* Server Icons Grid */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
gap: 24,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
{previousServers.map((server, index) => (
|
||||
<TVServerIcon
|
||||
key={server.address}
|
||||
name={server.name || ""}
|
||||
address={server.address}
|
||||
onPress={() => onServerSelect(server)}
|
||||
onLongPress={() => handleDeleteServer(server)}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add Server Button */}
|
||||
<TVAddIcon
|
||||
label={t("server.add_server")}
|
||||
onPress={onAddServer}
|
||||
hasTVPreferredFocus={!hasServers}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
127
components/login/TVUserIcon.tsx
Normal file
127
components/login/TVUserIcon.tsx
Normal file
@@ -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<View, TVUserIconProps>(
|
||||
(
|
||||
{ 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 (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
alignItems: "center",
|
||||
width: 160,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 16 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={{ position: "relative" }}>
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
overflow: "hidden",
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
marginBottom: 14,
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='person'
|
||||
size={56}
|
||||
color={
|
||||
focused ? "rgba(255,255,255,0.6)" : "rgba(255,255,255,0.4)"
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Security badge */}
|
||||
{hasSecurityProtection && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Ionicons name={getSecurityIcon()} size={16} color='#000' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
130
components/login/TVUserSelectionScreen.tsx
Normal file
130
components/login/TVUserSelectionScreen.tsx
Normal file
@@ -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<TVUserSelectionScreenProps> = ({
|
||||
server,
|
||||
onUserSelect,
|
||||
onAddUser,
|
||||
onChangeServer,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
const accounts = server.accounts || [];
|
||||
const hasAccounts = accounts.length > 0;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Server Info Header */}
|
||||
<View style={{ marginBottom: 48, alignItems: "center" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.title,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{server.name || server.address}
|
||||
</Text>
|
||||
{server.name && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "#9CA3AF",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{server.address.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "#6B7280",
|
||||
textAlign: "center",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{hasAccounts
|
||||
? t("login.select_user")
|
||||
: t("login.add_user_to_login")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* User Icons Grid with Back and Add buttons */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
gap: 24,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
{/* Back/Change Server Button (left) */}
|
||||
<TVBackIcon
|
||||
label={t("server.change_server")}
|
||||
onPress={onChangeServer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* User Icons */}
|
||||
{accounts.map((account, index) => (
|
||||
<TVUserIcon
|
||||
key={account.userId}
|
||||
username={account.username}
|
||||
securityType={account.securityType}
|
||||
onPress={() => onUserSelect(account)}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add User Button (right) */}
|
||||
<TVAddIcon
|
||||
label={t("login.add_user")}
|
||||
onPress={onAddUser}
|
||||
hasTVPreferredFocus={!hasAccounts}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
416
docs/tv-modal-guide.md
Normal file
416
docs/tv-modal-guide.md
Normal file
@@ -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<TVExampleModalData | null>(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 (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
{/* Background overlay */}
|
||||
<Pressable
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
}}
|
||||
onPress={() => router.back()}
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<BlurView
|
||||
intensity={80}
|
||||
tint="dark"
|
||||
style={{
|
||||
padding: 32,
|
||||
borderRadius: 16,
|
||||
minWidth: 400,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontSize: 24 }}>
|
||||
{modalData.title}
|
||||
</Text>
|
||||
{/* Modal content here */}
|
||||
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
hasTVPreferredFocus
|
||||
style={({ focused }) => ({
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
||||
})}
|
||||
>
|
||||
{({ focused }) => (
|
||||
<Text style={{ color: focused ? "#000" : "#fff" }}>
|
||||
Close
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add Stack.Screen Configuration
|
||||
|
||||
Add the modal route to `app/_layout.tsx`:
|
||||
|
||||
```typescript
|
||||
// In app/_layout.tsx, inside your Stack navigator
|
||||
<Stack.Screen
|
||||
name="(auth)/tv-example-modal"
|
||||
options={{
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
// In any component
|
||||
import { useTVExampleModal } from "@/hooks/useTVExampleModal";
|
||||
|
||||
const MyComponent = () => {
|
||||
const { openModal } = useTVExampleModal();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => openModal({ itemId: "123", title: "Example" })}
|
||||
>
|
||||
<Text>Open Modal</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={80}
|
||||
tint="dark"
|
||||
style={{
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingVertical: 32,
|
||||
}}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
isSelected={option.value === selectedValue}
|
||||
hasTVPreferredFocus={index === selectedIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: isSelected ? 2 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: focused ? "#000" : "#fff", fontSize: 18 }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 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<ModalType | null>(null);
|
||||
const isModalOpen = openModal !== null;
|
||||
|
||||
// 2. Each focusable component accepts disabled prop
|
||||
const TVFocusableButton: React.FC<{
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
}> = ({ onPress, disabled }) => (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
hasTVPreferredFocus={isFirst && !disabled}
|
||||
>
|
||||
{/* content */}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
// 3. Pass disabled to all background components when modal is open
|
||||
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
|
||||
```
|
||||
|
||||
### 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";
|
||||
|
||||
<TVFocusGuideView
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
>
|
||||
{/* Modal content - focus cannot escape */}
|
||||
</TVFocusGuideView>
|
||||
```
|
||||
|
||||
**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 |
|
||||
34
hooks/useTVAccountActionModal.ts
Normal file
34
hooks/useTVAccountActionModal.ts
Normal file
@@ -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 };
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
60
utils/atoms/selectedTVServer.ts
Normal file
60
utils/atoms/selectedTVServer.ts
Normal file
@@ -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<SelectedTVServerState | null>(
|
||||
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);
|
||||
}
|
||||
14
utils/atoms/tvAccountActionModal.ts
Normal file
14
utils/atoms/tvAccountActionModal.ts
Normal file
@@ -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<TVAccountActionModalState>(null);
|
||||
@@ -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<TVAccountSelectModalState>(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<TVServerActionModalState>(null);
|
||||
Reference in New Issue
Block a user