mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-02 13:26:28 +01:00
feat(tv): migrate login to white design with navigation modals
This commit is contained in:
@@ -466,6 +466,22 @@ function Layout() {
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='tv-server-action-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='tv-account-select-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
|
||||
159
app/tv-account-select-modal.tsx
Normal file
159
app/tv-account-select-modal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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 { Text } from "@/components/common/Text";
|
||||
import { TVAccountCard } from "@/components/login/TVAccountCard";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
export default function TVAccountSelectModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvAccountSelectModalAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const contentScale = useRef(new Animated.Value(0.9)).current;
|
||||
|
||||
// Animate in on mount
|
||||
useEffect(() => {
|
||||
overlayOpacity.setValue(0);
|
||||
contentScale.setValue(0.9);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(contentScale, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
store.set(tvAccountSelectModalAtom, null);
|
||||
};
|
||||
}, [overlayOpacity, contentScale]);
|
||||
|
||||
const handleClose = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
if (!modalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 80,
|
||||
opacity: overlayOpacity,
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: contentScale }],
|
||||
width: "100%",
|
||||
maxWidth: 700,
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
tint='dark'
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={{
|
||||
padding: 40,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("server.select_account")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
{modalState.server.name || modalState.server.address}
|
||||
</Text>
|
||||
|
||||
{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
|
||||
onPress={() => {
|
||||
modalState.onAddAccount();
|
||||
router.back();
|
||||
}}
|
||||
color='white'
|
||||
>
|
||||
{t("server.add_account")}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={handleClose}
|
||||
color='black'
|
||||
className='bg-neutral-800'
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
250
app/tv-server-action-modal.tsx
Normal file
250
app/tv-server-action-modal.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
TVFocusGuideView,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
// Action card component
|
||||
const TVServerActionCard: React.FC<{
|
||||
label: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
variant?: "default" | "destructive";
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
const isDestructive = variant === "destructive";
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
width: 180,
|
||||
height: 90,
|
||||
backgroundColor: focused
|
||||
? isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff"
|
||||
: isDestructive
|
||||
? "rgba(239, 68, 68, 0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={28}
|
||||
color={
|
||||
focused
|
||||
? isDestructive
|
||||
? "#fff"
|
||||
: "#000"
|
||||
: isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff"
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: focused
|
||||
? isDestructive
|
||||
? "#fff"
|
||||
: "#000"
|
||||
: isDestructive
|
||||
? "#ef4444"
|
||||
: "#fff",
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TVServerActionModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvServerActionModalAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
// Animate in on mount
|
||||
useEffect(() => {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(200);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
store.set(tvServerActionModalAtom, null);
|
||||
};
|
||||
}, [overlayOpacity, sheetTranslateY]);
|
||||
|
||||
const handleLogin = () => {
|
||||
modalState?.onLogin();
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
modalState?.onDelete();
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
if (!modalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
opacity: overlayOpacity,
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: "100%",
|
||||
transform: [{ translateY: sheetTranslateY }],
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={80}
|
||||
tint='dark'
|
||||
style={{
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={{
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{modalState.server.name || modalState.server.address}
|
||||
</Text>
|
||||
|
||||
{/* Horizontal options */}
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<TVServerActionCard
|
||||
label={t("common.login")}
|
||||
icon='log-in-outline'
|
||||
hasTVPreferredFocus
|
||||
onPress={handleLogin}
|
||||
/>
|
||||
<TVServerActionCard
|
||||
label={t("common.delete")}
|
||||
icon='trash-outline'
|
||||
variant='destructive'
|
||||
onPress={handleDelete}
|
||||
/>
|
||||
<TVServerActionCard
|
||||
label={t("common.cancel")}
|
||||
icon='close-outline'
|
||||
onPress={handleClose}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user