mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-01 07:58:08 +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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
32
utils/jellyfin/image/getUserImageUrl.ts
Normal file
32
utils/jellyfin/image/getUserImageUrl.ts
Normal 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()}`;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user