This commit is contained in:
Fredrik Burmester
2026-01-19 08:21:55 +01:00
parent a8c07a31d3
commit a173db9180
14 changed files with 1718 additions and 262 deletions

View File

@@ -119,7 +119,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
</View>
)}
</View>
{!item.UserData?.Played && <WatchedIndicator item={item} />}
<WatchedIndicator item={item} />
<ProgressBar item={item} />
</View>
);

View File

@@ -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 &&

View 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,
},
});

View File

@@ -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={{

View File

@@ -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);

View 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",
},
});

View 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",
},
});

View File

@@ -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>

View 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",
},
});

View File

@@ -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={[

View File

@@ -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={[

View File

@@ -98,7 +98,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
}}
/>
)}
{!episode.UserData?.Played && <WatchedIndicator item={episode} />}
<WatchedIndicator item={episode} />
<ProgressBar item={episode} />
</View>
</TVFocusablePoster>