diff --git a/components/login/TVServerIcon.tsx b/components/login/TVServerIcon.tsx index bfad0baf..1ef01c51 100644 --- a/components/login/TVServerIcon.tsx +++ b/components/login/TVServerIcon.tsx @@ -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( 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 ( ( { 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( 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", }} > - - {initial} - + + {initial} + + void; hasTVPreferredFocus?: boolean; disabled?: boolean; + serverAddress?: string; + userId?: string; + primaryImageTag?: string; } export const TVUserIcon = React.forwardRef( ( - { 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( const hasSecurityProtection = securityType !== "none"; + const imageUrl = + serverAddress && userId && primaryImageTag && !imageError + ? getUserImageUrl({ + serverAddress, + userId, + primaryImageTag, + width: 280, + }) + : null; + return ( ( { 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( alignItems: "center", }} > - + {imageUrl ? ( + setImageError(true)} + /> + ) : ( + + )} {/* Security badge */} diff --git a/components/login/TVUserSelectionScreen.tsx b/components/login/TVUserSelectionScreen.tsx index 2c1a0c3a..d9f74b37 100644 --- a/components/login/TVUserSelectionScreen.tsx +++ b/components/login/TVUserSelectionScreen.tsx @@ -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 = ({ server, onUserSelect, @@ -31,6 +43,32 @@ export const TVUserSelectionScreen: React.FC = ({ 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 ( = ({ onPress={() => onUserSelect(account)} hasTVPreferredFocus={index === 0} disabled={disabled} + serverAddress={server.address} + userId={account.userId} + primaryImageTag={account.primaryImageTag} /> ))} diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 97ba07e0..97f7a4e2 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -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, }); } } diff --git a/utils/jellyfin/image/getUserImageUrl.ts b/utils/jellyfin/image/getUserImageUrl.ts new file mode 100644 index 00000000..89e51f6a --- /dev/null +++ b/utils/jellyfin/image/getUserImageUrl.ts @@ -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()}`; +}; diff --git a/utils/secureCredentials.ts b/utils/secureCredentials.ts index f64a56d7..bb5f7713 100644 --- a/utils/secureCredentials.ts +++ b/utils/secureCredentials.ts @@ -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 { /** * 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 { } /** - * 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 { 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, + }); } }