feat(tv): new login design

This commit is contained in:
Fredrik Burmester
2026-01-31 11:49:15 +01:00
parent 85a74a9a6a
commit 1ec887c29e
6 changed files with 273 additions and 29 deletions

View File

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

View File

@@ -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 */}

View File

@@ -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}
/>
))}

View File

@@ -29,6 +29,7 @@ import { writeErrorLog, writeInfoLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import {
type AccountSecurityType,
addAccountToServer,
addServerToList,
deleteAccountCredential,
getAccountCredential,
@@ -287,6 +288,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
});
}
@@ -400,6 +402,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("token", credential.token);
storage.set("user", JSON.stringify(response.data));
// Update account info (in case user changed their avatar)
if (response.data.PrimaryImageTag !== credential.primaryImageTag) {
addAccountToServer(serverUrl, credential.serverName, {
userId: credential.userId,
username: credential.username,
securityType: credential.securityType,
savedAt: credential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
// Refresh plugin settings
await refreshStreamyfinPluginSettings();
} catch (error) {
@@ -451,11 +464,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("serverUrl", serverUrl);
storage.set("token", auth.data.AccessToken);
// Update the saved credential with new token
// Update the saved credential with new token and image tag
await updateAccountToken(
serverUrl,
auth.data.User.Id || "",
auth.data.AccessToken,
auth.data.User.PrimaryImageTag ?? undefined,
);
// Refresh plugin settings
@@ -542,6 +556,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}

View File

@@ -0,0 +1,32 @@
/**
* Retrieves the profile image URL for a Jellyfin user.
*
* @param serverAddress - The Jellyfin server base URL.
* @param userId - The user's ID.
* @param primaryImageTag - The user's primary image tag (required for the image to exist).
* @param width - The desired image width (default: 280).
* @returns The image URL or null if no image tag is provided.
*/
export const getUserImageUrl = ({
serverAddress,
userId,
primaryImageTag,
width = 280,
}: {
serverAddress: string;
userId: string;
primaryImageTag?: string | null;
width?: number;
}): string | null => {
if (!primaryImageTag) {
return null;
}
const params = new URLSearchParams({
tag: primaryImageTag,
quality: "90",
width: String(width),
});
return `${serverAddress}/Users/${userId}/Images/Primary?${params.toString()}`;
};

View File

@@ -22,6 +22,7 @@ export interface ServerCredential {
savedAt: number;
securityType: AccountSecurityType;
pinHash?: string;
primaryImageTag?: string;
}
/**
@@ -32,6 +33,7 @@ export interface SavedServerAccount {
username: string;
securityType: AccountSecurityType;
savedAt: number;
primaryImageTag?: string;
}
/**
@@ -131,6 +133,7 @@ export async function saveAccountCredential(
username: credential.username,
securityType: credential.securityType,
savedAt: credential.savedAt,
primaryImageTag: credential.primaryImageTag,
});
}
@@ -224,7 +227,7 @@ export async function clearAllCredentials(): Promise<void> {
/**
* Add or update an account in a server's accounts list.
*/
function addAccountToServer(
export function addAccountToServer(
serverUrl: string,
serverName: string,
account: SavedServerAccount,
@@ -475,19 +478,32 @@ export async function migrateToMultiAccount(): Promise<void> {
}
/**
* Update account's token after successful login.
* Update account's token and optionally other fields after successful login.
*/
export async function updateAccountToken(
serverUrl: string,
userId: string,
newToken: string,
primaryImageTag?: string,
): Promise<void> {
const credential = await getAccountCredential(serverUrl, userId);
if (credential) {
credential.token = newToken;
credential.savedAt = Date.now();
if (primaryImageTag !== undefined) {
credential.primaryImageTag = primaryImageTag;
}
const key = credentialKey(serverUrl, userId);
await SecureStore.setItemAsync(key, JSON.stringify(credential));
// Also update the account info in the server list
addAccountToServer(serverUrl, credential.serverName, {
userId: credential.userId,
username: credential.username,
securityType: credential.securityType,
savedAt: credential.savedAt,
primaryImageTag: credential.primaryImageTag,
});
}
}