mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-24 10:16:25 +00:00
feat(tv): new login design
This commit is contained in:
@@ -1,9 +1,83 @@
|
||||
import React from "react";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import React, { useMemo } from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
// Sci-fi gradient color pairs (from, to) - cyberpunk/neon vibes
|
||||
const SERVER_GRADIENTS: [string, string][] = [
|
||||
["#00D4FF", "#0066FF"], // Cyan to Blue
|
||||
["#FF00E5", "#7B00FF"], // Magenta to Purple
|
||||
["#00FF94", "#00B4D8"], // Neon Green to Cyan
|
||||
["#FF6B35", "#F72585"], // Orange to Pink
|
||||
["#4CC9F0", "#7209B7"], // Sky Blue to Violet
|
||||
["#06D6A0", "#118AB2"], // Mint to Ocean Blue
|
||||
["#FFD60A", "#FF006E"], // Yellow to Hot Pink
|
||||
["#8338EC", "#3A86FF"], // Purple to Blue
|
||||
["#FB5607", "#FFBE0B"], // Orange to Gold
|
||||
["#00F5D4", "#00BBF9"], // Aqua to Azure
|
||||
["#F15BB5", "#9B5DE5"], // Pink to Lavender
|
||||
["#00C49A", "#00509D"], // Teal to Navy
|
||||
["#E63946", "#F4A261"], // Red to Peach
|
||||
["#2EC4B6", "#011627"], // Turquoise to Dark Blue
|
||||
["#FF0099", "#493240"], // Hot Pink to Plum
|
||||
["#11998E", "#38EF7D"], // Teal to Lime
|
||||
["#FC466B", "#3F5EFB"], // Pink to Indigo
|
||||
["#C471ED", "#12C2E9"], // Orchid to Sky
|
||||
["#F857A6", "#FF5858"], // Pink to Coral
|
||||
["#00B09B", "#96C93D"], // Emerald to Lime
|
||||
["#7F00FF", "#E100FF"], // Violet to Magenta
|
||||
["#1FA2FF", "#12D8FA"], // Blue to Cyan
|
||||
["#F09819", "#EDDE5D"], // Orange to Yellow
|
||||
["#FF416C", "#FF4B2B"], // Pink to Red Orange
|
||||
["#654EA3", "#EAAFC8"], // Purple to Rose
|
||||
["#00C6FF", "#0072FF"], // Light Blue to Blue
|
||||
["#F7971E", "#FFD200"], // Orange to Gold
|
||||
["#56AB2F", "#A8E063"], // Green to Lime
|
||||
["#DA22FF", "#9733EE"], // Magenta to Purple
|
||||
["#02AAB0", "#00CDAC"], // Teal variations
|
||||
["#ED213A", "#93291E"], // Red to Dark Red
|
||||
["#FDC830", "#F37335"], // Yellow to Orange
|
||||
["#00B4DB", "#0083B0"], // Ocean Blue
|
||||
["#C33764", "#1D2671"], // Berry to Navy
|
||||
["#E55D87", "#5FC3E4"], // Pink to Sky Blue
|
||||
["#403B4A", "#E7E9BB"], // Dark to Cream
|
||||
["#F2709C", "#FF9472"], // Rose to Peach
|
||||
["#1D976C", "#93F9B9"], // Forest to Mint
|
||||
["#CC2B5E", "#753A88"], // Crimson to Purple
|
||||
["#42275A", "#734B6D"], // Plum shades
|
||||
["#BDC3C7", "#2C3E50"], // Silver to Slate
|
||||
["#DE6262", "#FFB88C"], // Salmon to Apricot
|
||||
["#06BEB6", "#48B1BF"], // Teal shades
|
||||
["#EB3349", "#F45C43"], // Red to Orange Red
|
||||
["#DD5E89", "#F7BB97"], // Pink to Tan
|
||||
["#56CCF2", "#2F80ED"], // Sky to Blue
|
||||
["#007991", "#78FFD6"], // Deep Teal to Mint
|
||||
["#C6FFDD", "#FBD786"], // Mint to Yellow
|
||||
["#F953C6", "#B91D73"], // Pink to Magenta
|
||||
["#B24592", "#F15F79"], // Purple to Coral
|
||||
];
|
||||
|
||||
// Generate a consistent gradient index based on URL (deterministic hash)
|
||||
// Uses cyrb53 hash - fast and good distribution
|
||||
const getGradientForString = (str: string): [string, string] => {
|
||||
let h1 = 0xdeadbeef;
|
||||
let h2 = 0x41c6ce57;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
|
||||
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
|
||||
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
||||
const index = Math.abs(hash) % SERVER_GRADIENTS.length;
|
||||
return SERVER_GRADIENTS[index];
|
||||
};
|
||||
|
||||
export interface TVServerIconProps {
|
||||
name: string;
|
||||
address: string;
|
||||
@@ -33,6 +107,14 @@ export const TVServerIcon = React.forwardRef<View, TVServerIconProps>(
|
||||
const displayName = name || address;
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
|
||||
// Get a consistent gradient based on the server URL (deterministic)
|
||||
// Use address as primary key, fallback to name + displayName for uniqueness
|
||||
const hashKey = address || name || displayName;
|
||||
const [gradientStart, gradientEnd] = useMemo(
|
||||
() => getGradientForString(hashKey),
|
||||
[hashKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
@@ -50,10 +132,10 @@ export const TVServerIcon = React.forwardRef<View, TVServerIconProps>(
|
||||
{
|
||||
alignItems: "center",
|
||||
width: 160,
|
||||
shadowColor: "#fff",
|
||||
shadowColor: gradientStart,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 16 : 0,
|
||||
shadowOpacity: focused ? 0.7 : 0,
|
||||
shadowRadius: focused ? 24 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -63,25 +145,36 @@ export const TVServerIcon = React.forwardRef<View, TVServerIconProps>(
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
overflow: "hidden",
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
marginBottom: 14,
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
<LinearGradient
|
||||
colors={[gradientStart, gradientEnd]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={{
|
||||
fontSize: 48,
|
||||
fontWeight: "bold",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: focused ? 1 : 0.85,
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 48,
|
||||
fontWeight: "bold",
|
||||
color: "#fff",
|
||||
textShadowColor: "rgba(0,0,0,0.3)",
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Image } from "expo-image";
|
||||
import React, { useState } from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
|
||||
import type { AccountSecurityType } from "@/utils/secureCredentials";
|
||||
|
||||
export interface TVUserIconProps {
|
||||
@@ -12,16 +14,29 @@ export interface TVUserIconProps {
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
serverAddress?: string;
|
||||
userId?: string;
|
||||
primaryImageTag?: string;
|
||||
}
|
||||
|
||||
export const TVUserIcon = React.forwardRef<View, TVUserIconProps>(
|
||||
(
|
||||
{ username, securityType, onPress, hasTVPreferredFocus, disabled = false },
|
||||
{
|
||||
username,
|
||||
securityType,
|
||||
onPress,
|
||||
hasTVPreferredFocus,
|
||||
disabled = false,
|
||||
serverAddress,
|
||||
userId,
|
||||
primaryImageTag,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
|
||||
switch (securityType) {
|
||||
@@ -36,6 +51,16 @@ export const TVUserIcon = React.forwardRef<View, TVUserIconProps>(
|
||||
|
||||
const hasSecurityProtection = securityType !== "none";
|
||||
|
||||
const imageUrl =
|
||||
serverAddress && userId && primaryImageTag && !imageError
|
||||
? getUserImageUrl({
|
||||
serverAddress,
|
||||
userId,
|
||||
primaryImageTag,
|
||||
width: 280,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
@@ -52,6 +77,7 @@ export const TVUserIcon = React.forwardRef<View, TVUserIconProps>(
|
||||
{
|
||||
alignItems: "center",
|
||||
width: 160,
|
||||
overflow: "visible",
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
@@ -76,13 +102,22 @@ export const TVUserIcon = React.forwardRef<View, TVUserIconProps>(
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='person'
|
||||
size={56}
|
||||
color={
|
||||
focused ? "rgba(255,255,255,0.6)" : "rgba(255,255,255,0.4)"
|
||||
}
|
||||
/>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: 140, height: 140 }}
|
||||
contentFit='cover'
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Ionicons
|
||||
name='person'
|
||||
size={56}
|
||||
color={
|
||||
focused ? "rgba(255,255,255,0.6)" : "rgba(255,255,255,0.4)"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Security badge */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "i18next";
|
||||
import React from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import React, { useEffect } from "react";
|
||||
import { BackHandler, Platform, ScrollView, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import type {
|
||||
@@ -19,6 +19,18 @@ interface TVUserSelectionScreenProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// TV event handler with fallback for non-TV platforms
|
||||
let useTVEventHandler: (callback: (evt: any) => void) => void;
|
||||
if (Platform.isTV) {
|
||||
try {
|
||||
useTVEventHandler = require("react-native").useTVEventHandler;
|
||||
} catch {
|
||||
useTVEventHandler = () => {};
|
||||
}
|
||||
} else {
|
||||
useTVEventHandler = () => {};
|
||||
}
|
||||
|
||||
export const TVUserSelectionScreen: React.FC<TVUserSelectionScreenProps> = ({
|
||||
server,
|
||||
onUserSelect,
|
||||
@@ -31,6 +43,32 @@ export const TVUserSelectionScreen: React.FC<TVUserSelectionScreenProps> = ({
|
||||
const accounts = server.accounts || [];
|
||||
const hasAccounts = accounts.length > 0;
|
||||
|
||||
// Handle TV remote back/menu button
|
||||
useTVEventHandler((evt) => {
|
||||
if (!evt || disabled) return;
|
||||
if (evt.eventType === "menu" || evt.eventType === "back") {
|
||||
onChangeServer();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Android TV back button
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) return;
|
||||
|
||||
const handleBackPress = () => {
|
||||
if (disabled) return false;
|
||||
onChangeServer();
|
||||
return true;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
handleBackPress,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [onChangeServer, disabled]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
@@ -113,6 +151,9 @@ export const TVUserSelectionScreen: React.FC<TVUserSelectionScreenProps> = ({
|
||||
onPress={() => onUserSelect(account)}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
disabled={disabled}
|
||||
serverAddress={server.address}
|
||||
userId={account.userId}
|
||||
primaryImageTag={account.primaryImageTag}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user