mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-11 03:46:19 +00:00
wip
This commit is contained in:
@@ -119,7 +119,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||
<WatchedIndicator item={item} />
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
if (Platform.isTV) {
|
||||
// TV: Show white checkmark when watched
|
||||
if (
|
||||
item.UserData?.Played &&
|
||||
(item.Type === "Movie" || item.Type === "Episode")
|
||||
) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
borderRadius: 14,
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='checkmark' size={18} color='black' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mobile: Show purple triangle for unwatched
|
||||
return (
|
||||
<>
|
||||
{item.UserData?.Played === false &&
|
||||
|
||||
141
components/inputs/TVPinInput.tsx
Normal file
141
components/inputs/TVPinInput.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
type TextInputProps,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface TVPinInputProps
|
||||
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
length?: number;
|
||||
label?: string;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
export interface TVPinInputRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const TVPinInputComponent = React.forwardRef<TVPinInputRef, TVPinInputProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
value,
|
||||
onChangeText,
|
||||
length = 4,
|
||||
label,
|
||||
hasTVPreferredFocus,
|
||||
placeholder,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const animateFocus = (focused: boolean) => {
|
||||
Animated.timing(scale, {
|
||||
toValue: focused ? 1.02 : 1,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const handleChangeText = (text: string) => {
|
||||
// Only allow numeric input and limit to length
|
||||
const numericText = text.replace(/[^0-9]/g, "").slice(0, length);
|
||||
onChangeText(numericText);
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
animateFocus(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
borderColor: isFocused ? "#6366F1" : "#374151",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChangeText={handleChangeText}
|
||||
keyboardType='number-pad'
|
||||
maxLength={length}
|
||||
secureTextEntry
|
||||
placeholder={placeholder || `Enter ${length}-digit PIN`}
|
||||
placeholderTextColor='#6B7280'
|
||||
style={styles.input}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
animateFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TVPinInputComponent.displayName = "TVPinInput";
|
||||
|
||||
export const TVPinInput = TVPinInputComponent;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: "#1F2937",
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 4,
|
||||
minWidth: 280,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 4,
|
||||
marginTop: 8,
|
||||
},
|
||||
input: {
|
||||
fontSize: 24,
|
||||
color: "#fff",
|
||||
fontWeight: "500",
|
||||
textAlign: "center",
|
||||
height: 56,
|
||||
letterSpacing: 8,
|
||||
},
|
||||
});
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
interface TVInputProps extends TextInputProps {
|
||||
label?: string;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TVInput: React.FC<TVInputProps> = ({
|
||||
label,
|
||||
placeholder,
|
||||
hasTVPreferredFocus,
|
||||
disabled = false,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
@@ -49,7 +51,9 @@ export const TVInput: React.FC<TVInputProps> = ({
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
|
||||
@@ -19,17 +19,15 @@ 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,
|
||||
TVServerActionSheet,
|
||||
} from "@/components/login/TVPreviousServersList";
|
||||
import { TVSaveAccountModal } from "@/components/login/TVSaveAccountModal";
|
||||
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
|
||||
import { TVServerCard } from "@/components/login/TVServerCard";
|
||||
import { PasswordEntryModal } from "@/components/PasswordEntryModal";
|
||||
import { PINEntryModal } from "@/components/PINEntryModal";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
type AccountSecurityType,
|
||||
@@ -42,10 +40,11 @@ const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
});
|
||||
|
||||
const TVBackButton: React.FC<{ onPress: () => void; label: string }> = ({
|
||||
onPress,
|
||||
label,
|
||||
}) => {
|
||||
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;
|
||||
|
||||
@@ -70,6 +69,8 @@ const TVBackButton: React.FC<{ onPress: () => void; label: string }> = ({
|
||||
animateFocus(false);
|
||||
}}
|
||||
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
@@ -161,12 +162,12 @@ export const TVLogin: React.FC = () => {
|
||||
useState<SavedServer | null>(null);
|
||||
const [actionSheetKey, setActionSheetKey] = useState(0);
|
||||
|
||||
// Server discovery
|
||||
const {
|
||||
servers: discoveredServers,
|
||||
isSearching,
|
||||
startDiscovery,
|
||||
} = useJellyfinDiscovery();
|
||||
// Track if any modal is open to disable background focus
|
||||
const isAnyModalOpen =
|
||||
showSaveModal ||
|
||||
pinModalVisible ||
|
||||
passwordModalVisible ||
|
||||
showServerActionSheet;
|
||||
|
||||
// Auto login from URL params
|
||||
useEffect(() => {
|
||||
@@ -470,6 +471,7 @@ export const TVLogin: React.FC = () => {
|
||||
<TVBackButton
|
||||
onPress={() => removeServer()}
|
||||
label={t("login.change_server")}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
@@ -513,6 +515,7 @@ export const TVLogin: React.FC = () => {
|
||||
textContentType='username'
|
||||
returnKeyType='next'
|
||||
hasTVPreferredFocus
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -528,6 +531,7 @@ export const TVLogin: React.FC = () => {
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
returnKeyType='done'
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -537,6 +541,7 @@ export const TVLogin: React.FC = () => {
|
||||
value={saveAccount}
|
||||
onValueChange={setSaveAccount}
|
||||
label={t("save_account.save_for_later")}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -623,11 +628,12 @@ export const TVLogin: React.FC = () => {
|
||||
textContentType='URL'
|
||||
returnKeyType='done'
|
||||
hasTVPreferredFocus
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Connect Button */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Button
|
||||
onPress={() => handleConnect(serverURL)}
|
||||
loading={loadingServerCheck}
|
||||
@@ -637,59 +643,6 @@ export const TVLogin: React.FC = () => {
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Server Discovery */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Button
|
||||
onPress={startDiscovery}
|
||||
color='black'
|
||||
className='bg-neutral-800'
|
||||
>
|
||||
{isSearching
|
||||
? t("server.searching")
|
||||
: t("server.search_for_local_servers")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Discovered Servers */}
|
||||
{discoveredServers.length > 0 && (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("server.servers")}
|
||||
</Text>
|
||||
<View style={{ gap: 16 }}>
|
||||
{discoveredServers.map((server) => (
|
||||
<TVServerCard
|
||||
key={server.address}
|
||||
title={server.serverName || server.address}
|
||||
subtitle={
|
||||
server.serverName ? server.address : undefined
|
||||
}
|
||||
onPress={() => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) {
|
||||
setServerName(server.serverName);
|
||||
}
|
||||
handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Previous Servers */}
|
||||
<View style={{ paddingHorizontal: 8 }}>
|
||||
<TVPreviousServersList
|
||||
@@ -701,6 +654,7 @@ export const TVLogin: React.FC = () => {
|
||||
onPasswordRequired={handlePasswordRequired}
|
||||
onServerAction={handleServerAction}
|
||||
loginServerOverride={loginTriggerServer}
|
||||
disabled={isAnyModalOpen}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -709,7 +663,7 @@ export const TVLogin: React.FC = () => {
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Save Account Modal */}
|
||||
<SaveAccountModal
|
||||
<TVSaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
@@ -720,7 +674,7 @@ export const TVLogin: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* PIN Entry Modal */}
|
||||
<PINEntryModal
|
||||
<TVPINEntryModal
|
||||
visible={pinModalVisible}
|
||||
onClose={() => {
|
||||
setPinModalVisible(false);
|
||||
@@ -735,7 +689,7 @@ export const TVLogin: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* Password Entry Modal */}
|
||||
<PasswordEntryModal
|
||||
<TVPasswordEntryModal
|
||||
visible={passwordModalVisible}
|
||||
onClose={() => {
|
||||
setPasswordModalVisible(false);
|
||||
|
||||
327
components/login/TVPINEntryModal.tsx
Normal file
327
components/login/TVPINEntryModal.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
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 {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onForgotPIN?: () => void;
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// Forgot PIN Button
|
||||
const TVForgotPINButton: React.FC<{
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: focused
|
||||
? "rgba(168, 85, 247, 0.2)"
|
||||
: "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#d8b4fe" : "#a855f7",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onForgotPIN,
|
||||
serverUrl,
|
||||
userId,
|
||||
username,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [pinCode, setPinCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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 shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Reset state when opening
|
||||
setPinCode("");
|
||||
setError(null);
|
||||
setIsVerifying(false);
|
||||
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(200);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
const shake = () => {
|
||||
Animated.sequence([
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: 15,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: -15,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: 15,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: 0,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
};
|
||||
|
||||
const handlePinChange = async (value: string) => {
|
||||
setPinCode(value);
|
||||
setError(null);
|
||||
|
||||
// Auto-verify when 4 digits entered
|
||||
if (value.length === 4) {
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
const isValid = await verifyAccountPIN(serverUrl, userId, value);
|
||||
if (isValid) {
|
||||
onSuccess();
|
||||
setPinCode("");
|
||||
} else {
|
||||
setError(t("pin.invalid_pin"));
|
||||
shake();
|
||||
setPinCode("");
|
||||
}
|
||||
} catch {
|
||||
setError(t("pin.invalid_pin"));
|
||||
shake();
|
||||
setPinCode("");
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPIN = () => {
|
||||
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.continue"),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
onClose();
|
||||
onForgotPIN?.();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
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>
|
||||
|
||||
{/* PIN Input */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Forgot PIN */}
|
||||
{isReady && onForgotPIN && (
|
||||
<View style={styles.forgotContainer}>
|
||||
<TVForgotPINButton
|
||||
onPress={handleForgotPIN}
|
||||
label={t("pin.forgot_pin")}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
color: "#fff",
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
},
|
||||
pinContainer: {
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorText: {
|
||||
color: "#ef4444",
|
||||
fontSize: 14,
|
||||
marginTop: 16,
|
||||
textAlign: "center",
|
||||
},
|
||||
verifyingText: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
fontSize: 14,
|
||||
marginTop: 16,
|
||||
textAlign: "center",
|
||||
},
|
||||
forgotContainer: {
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
337
components/login/TVPasswordEntryModal.tsx
Normal file
337
components/login/TVPasswordEntryModal.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv";
|
||||
|
||||
interface TVPasswordEntryModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (password: string) => Promise<void>;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// TV Submit Button
|
||||
const TVSubmitButton: React.FC<{
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}> = ({ onPress, label, loading = false, disabled = false }) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isDisabled}
|
||||
focusable={!isDisabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "#a855f7"
|
||||
: isDisabled
|
||||
? "#4a4a4a"
|
||||
: "#7c3aed",
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 10,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
minWidth: 120,
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size='small' color='#fff' />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name='log-in-outline' size={20} color='#fff' />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// TV Focusable Password Input
|
||||
const TVPasswordInput: React.FC<{
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder: string;
|
||||
onSubmitEditing: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
onSubmitEditing,
|
||||
hasTVPreferredFocus,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
onFocus={() => {
|
||||
handleFocus();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: "#1F2937",
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: focused ? "#6366F1" : "#374151",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor='#6B7280'
|
||||
secureTextEntry
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 18,
|
||||
}}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
returnKeyType='done'
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSubmit,
|
||||
username,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Reset state when opening
|
||||
setPassword("");
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(200);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password) {
|
||||
setError(t("password.enter_password"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSubmit(password);
|
||||
setPassword("");
|
||||
} catch {
|
||||
setError(t("password.invalid_password"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("password.enter_password")}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{t("password.enter_password_for", { username })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
{isReady && (
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>{t("login.password")}</Text>
|
||||
<TVPasswordInput
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("login.password")}
|
||||
onSubmitEditing={handleSubmit}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
{isReady && (
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVSubmitButton
|
||||
onPress={handleSubmit}
|
||||
label={t("login.login")}
|
||||
loading={isLoading}
|
||||
disabled={!password}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
color: "#fff",
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
},
|
||||
inputContainer: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 20,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorText: {
|
||||
color: "#ef4444",
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonContainer: {
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
@@ -223,6 +223,8 @@ interface TVPreviousServersListProps {
|
||||
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 the action sheet for use in parent components
|
||||
@@ -236,6 +238,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
||||
onPasswordRequired,
|
||||
onServerAction,
|
||||
loginServerOverride,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [_previousServers, setPreviousServers] =
|
||||
@@ -416,6 +419,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
||||
securityIcon={getSecurityIcon(server)}
|
||||
isLoading={loadingServer === server.address}
|
||||
onPress={() => handleServerPress(server)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
435
components/login/TVSaveAccountModal.tsx
Normal file
435
components/login/TVSaveAccountModal.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
|
||||
import { TVOptionCard, useTVFocusAnimation } from "@/components/tv";
|
||||
import type { AccountSecurityType } from "@/utils/secureCredentials";
|
||||
|
||||
interface TVSaveAccountModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface SecurityOption {
|
||||
type: AccountSecurityType;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
}
|
||||
|
||||
const SECURITY_OPTIONS: SecurityOption[] = [
|
||||
{
|
||||
type: "none",
|
||||
titleKey: "save_account.no_protection",
|
||||
descriptionKey: "save_account.no_protection_desc",
|
||||
icon: "flash-outline",
|
||||
},
|
||||
{
|
||||
type: "pin",
|
||||
titleKey: "save_account.pin_code",
|
||||
descriptionKey: "save_account.pin_code_desc",
|
||||
icon: "keypad-outline",
|
||||
},
|
||||
{
|
||||
type: "password",
|
||||
titleKey: "save_account.password",
|
||||
descriptionKey: "save_account.password_desc",
|
||||
icon: "lock-closed-outline",
|
||||
},
|
||||
];
|
||||
|
||||
// Custom Save Button with TV focus
|
||||
const TVSaveButton: React.FC<{
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}> = ({ onPress, label, disabled = false, hasTVPreferredFocus = false }) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "#a855f7"
|
||||
: disabled
|
||||
? "#4a4a4a"
|
||||
: "#7c3aed",
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 10,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name='checkmark' size={20} color='#fff' />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Back Button for PIN step
|
||||
const TVBackButton: React.FC<{
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={20}
|
||||
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#000" : "rgba(255,255,255,0.8)",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVSaveAccountModal: React.FC<TVSaveAccountModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSave,
|
||||
username,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [step, setStep] = useState<"select" | "pin">("select");
|
||||
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
|
||||
const [pinCode, setPinCode] = useState("");
|
||||
const [pinError, setPinError] = useState<string | null>(null);
|
||||
const pinInputRef = useRef<TVPinInputRef>(null);
|
||||
|
||||
// Use useState for focus tracking (per TV focus guide)
|
||||
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Reset state when opening
|
||||
setStep("select");
|
||||
setSelectedType("none");
|
||||
setPinCode("");
|
||||
setPinError(null);
|
||||
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(200);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
// Focus the first card when ready
|
||||
useEffect(() => {
|
||||
if (isReady && firstCardRef && step === "select") {
|
||||
const timer = setTimeout(() => {
|
||||
(firstCardRef as any)?.requestTVFocus?.();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isReady, firstCardRef, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "pin" && isReady) {
|
||||
const timer = setTimeout(() => {
|
||||
pinInputRef.current?.focus();
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [step, isReady]);
|
||||
|
||||
const handleOptionSelect = (type: AccountSecurityType) => {
|
||||
setSelectedType(type);
|
||||
if (type === "pin") {
|
||||
setStep("pin");
|
||||
setPinCode("");
|
||||
setPinError(null);
|
||||
} else {
|
||||
// For "none" or "password", save immediately
|
||||
onSave(type);
|
||||
resetAndClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinSave = () => {
|
||||
if (pinCode.length !== 4) {
|
||||
setPinError(t("pin.enter_4_digits"));
|
||||
return;
|
||||
}
|
||||
onSave("pin", pinCode);
|
||||
resetAndClose();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setStep("select");
|
||||
setPinCode("");
|
||||
setPinError(null);
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setStep("select");
|
||||
setSelectedType("none");
|
||||
setPinCode("");
|
||||
setPinError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("save_account.title")}</Text>
|
||||
<Text style={styles.subtitle}>{username}</Text>
|
||||
</View>
|
||||
|
||||
{step === "select" ? (
|
||||
// Security selection step
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("save_account.security_option")}
|
||||
</Text>
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{SECURITY_OPTIONS.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={option.type}
|
||||
ref={index === 0 ? setFirstCardRef : undefined}
|
||||
label={t(option.titleKey)}
|
||||
sublabel={t(option.descriptionKey)}
|
||||
selected={selectedType === option.type}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
onPress={() => handleOptionSelect(option.type)}
|
||||
width={220}
|
||||
height={100}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// PIN entry step
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>{t("pin.setup_pin")}</Text>
|
||||
<View style={styles.pinContainer}>
|
||||
<TVPinInput
|
||||
ref={pinInputRef}
|
||||
value={pinCode}
|
||||
onChangeText={(text) => {
|
||||
setPinCode(text);
|
||||
setPinError(null);
|
||||
}}
|
||||
length={4}
|
||||
autoFocus
|
||||
/>
|
||||
{pinError && <Text style={styles.errorText}>{pinError}</Text>}
|
||||
</View>
|
||||
|
||||
{isReady && (
|
||||
<View style={styles.buttonRow}>
|
||||
<TVBackButton
|
||||
onPress={handleBack}
|
||||
label={t("common.back")}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
<TVSaveButton
|
||||
onPress={handlePinSave}
|
||||
label={t("save_account.save_button")}
|
||||
disabled={pinCode.length !== 4}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
color: "#fff",
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
},
|
||||
buttonRow: {
|
||||
marginTop: 20,
|
||||
paddingHorizontal: 48,
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
alignItems: "center",
|
||||
},
|
||||
pinContainer: {
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
errorText: {
|
||||
color: "#ef4444",
|
||||
fontSize: 14,
|
||||
marginTop: 12,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,7 @@ interface TVSaveAccountToggleProps {
|
||||
onValueChange: (value: boolean) => void;
|
||||
label: string;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||
@@ -15,6 +16,7 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||
onValueChange,
|
||||
label,
|
||||
hasTVPreferredFocus,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
@@ -52,7 +54,9 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||
onPress={() => onValueChange(!value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
|
||||
@@ -17,6 +17,7 @@ interface TVServerCardProps {
|
||||
isLoading?: boolean;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||
@@ -26,6 +27,7 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||
isLoading,
|
||||
onPress,
|
||||
hasTVPreferredFocus,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
@@ -58,13 +60,16 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||
animateFocus(false);
|
||||
};
|
||||
|
||||
const isDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isLoading}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
disabled={isDisabled}
|
||||
focusable={!isDisabled}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !isDisabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
|
||||
@@ -98,7 +98,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!episode.UserData?.Played && <WatchedIndicator item={episode} />}
|
||||
<WatchedIndicator item={episode} />
|
||||
<ProgressBar item={episode} />
|
||||
</View>
|
||||
</TVFocusablePoster>
|
||||
|
||||
Reference in New Issue
Block a user