fix: tv login layout (#972)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
lance chant
2025-08-29 13:30:49 +02:00
committed by GitHub
parent af323d4679
commit d3609e3499
3 changed files with 224 additions and 12 deletions

View File

@@ -207,7 +207,143 @@ const Login: React.FC = () => {
}
};
return (
return Platform.isTV ? (
// TV layout
<SafeAreaView className='flex-1 bg-black'>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
{api?.basePath ? (
// ------------ Username/Password view ------------
<View className='flex-1 items-center justify-center'>
{/* Safe centered column with max width so TV doesnt stretch too far */}
<View className='w-[92%] max-w-[900px] px-2 -mt-12'>
<Text className='text-3xl font-bold text-white mb-1'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-500'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400 mb-6'>
{api.basePath}
</Text>
{/* Username */}
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text: string) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='oneTimeCode'
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
/>
{/* Password */}
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text: string) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
/>
<View className='mt-4'>
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
</View>
<View className='mt-3'>
<Button
onPress={handleQuickConnect}
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</View>
</View>
) : (
// ------------ Server connect view ------------
<View className='flex-1 items-center justify-center'>
<View className='w-[92%] max-w-[900px] -mt-2'>
<View className='items-center mb-1'>
<Image
source={require("@/assets/images/icon-ios-plain.png")}
style={{ width: 110, height: 110 }}
contentFit='contain'
/>
</View>
<Text className='text-white text-4xl font-bold text-center'>
Streamyfin
</Text>
<Text className='text-neutral-400 text-base text-left mt-2 mb-1'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Full-width Input with clear focus ring */}
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
{/* Full-width primary button */}
<View className='mt-4'>
<Button
onPress={async () => {
await handleConnect(serverURL);
}}
>
{t("server.connect_button")}
</Button>
</View>
{/* Lists stay full width but inside max width container */}
<View className='mt-2'>
<JellyfinServerDiscovery
onServerSelect={async (server: any) => {
setServerURL(server.address);
if (server.serverName) setServerName(server.serverName);
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s: any) => {
await handleConnect(s.address);
}}
/>
</View>
</View>
</View>
)}
</KeyboardAvoidingView>
</SafeAreaView>
) : (
// Mobile layout
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}

View File

@@ -1,6 +1,20 @@
import type React from "react";
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import {
type PropsWithChildren,
type ReactNode,
useMemo,
useRef,
useState,
} from "react";
import {
Animated,
Easing,
Platform,
Pressable,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { Loader } from "./Loader";
@@ -31,10 +45,23 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
justify = "center",
...props
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 130,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const colorClasses = useMemo(() => {
switch (color) {
case "purple":
return "bg-purple-600 active:bg-purple-700";
return focused
? "bg-purple-500 border-2 border-white"
: "bg-purple-600 border border-purple-700";
case "red":
return "bg-red-600";
case "black":
@@ -42,11 +69,43 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
case "transparent":
return "bg-transparent";
}
}, [color]);
}, [color, focused]);
const lightHapticFeedback = useHaptic("light");
return (
return Platform.isTV ? (
<Pressable
className='w-full'
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.08);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.9 : 0,
shadowRadius: focused ? 18 : 0,
elevation: focused ? 12 : 0, // Android glow
}}
>
<View
className={`rounded-2xl py-5 items-center justify-center
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
${className}`}
>
<Text className='text-white text-xl font-bold'>{children}</Text>
</View>
</Animated.View>
</Pressable>
) : (
<TouchableOpacity
className={`
p-3 rounded-xl items-center justify-center

View File

@@ -1,23 +1,40 @@
import React from "react";
import React, { useState } from "react";
import {
Platform,
TextInput,
type TextInputProps,
TouchableOpacity,
} from "react-native";
export function Input(props: TextInputProps) {
const { style, ...otherProps } = props;
interface InputProps extends TextInputProps {
extraClassName?: string; // new prop for additional classes
}
export function Input(props: InputProps) {
const { style, extraClassName = "", ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false);
return Platform.isTV ? (
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
<TextInput
ref={inputRef}
className='p-4 rounded-xl bg-neutral-900'
className={`
w-full text-lg px-5 py-4 rounded-2xl
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-transparent"}
text-white ${extraClassName}
`}
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}
style={[
style,
{
backgroundColor: isFocused ? "#ffffff88" : "#8f8d8d88",
},
]}
placeholderTextColor={"#ffffffff"}
clearButtonMode='while-editing'
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...otherProps}
/>
</TouchableOpacity>