mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-30 15:08:25 +00:00
fix(tv): correct episode image priority and scale animation in posters
This commit is contained in:
575
components/tv/TVPosterCard.tsx
Normal file
575
components/tv/TVPosterCard.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
export interface TVPosterCardProps {
|
||||
item: BaseItemDto;
|
||||
/** Poster orientation: vertical = 10:15 (portrait), horizontal = 16:9 (landscape) */
|
||||
orientation?: "vertical" | "horizontal";
|
||||
/** Show text below the poster (title, subtitle) - default: true */
|
||||
showText?: boolean;
|
||||
/** Show progress bar - default: true for items with progress */
|
||||
showProgress?: boolean;
|
||||
/** Show watched indicator - default: true */
|
||||
showWatchedIndicator?: boolean;
|
||||
|
||||
// Focus props
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
/** When true, the item remains focusable even when disabled (for navigation purposes) */
|
||||
focusableWhenDisabled?: boolean;
|
||||
|
||||
/** Shows a "Now Playing" badge on the card */
|
||||
isCurrent?: boolean;
|
||||
/** Show a play button overlay */
|
||||
showPlayButton?: boolean;
|
||||
|
||||
// Handlers
|
||||
onPress: () => void;
|
||||
onLongPress?: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
|
||||
/** Setter function for the ref (for focus guide destinations) */
|
||||
refSetter?: (ref: View | null) => void;
|
||||
|
||||
/** Custom width - overrides default based on orientation */
|
||||
width?: number;
|
||||
|
||||
/** Custom style for the outer container */
|
||||
style?: ViewStyle;
|
||||
|
||||
/** Glow color for focus state */
|
||||
glowColor?: "white" | "purple";
|
||||
|
||||
/** Scale amount for focus animation */
|
||||
scaleAmount?: number;
|
||||
|
||||
/** Custom image URL getter - if not provided, uses smart URL logic */
|
||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* TVPosterCard - Unified poster component for TV interface.
|
||||
*
|
||||
* Combines image rendering, focus handling, and text display into a single component.
|
||||
* Supports both portrait (10:15) and landscape (16:9) orientations.
|
||||
*
|
||||
* Features:
|
||||
* - Glass effect on tvOS 26+ with fallback
|
||||
* - Focus handling with scale animation and glow
|
||||
* - Progress bar and watched indicator
|
||||
* - Smart subtitle text based on item type
|
||||
* - "Now Playing" badge for current items
|
||||
*/
|
||||
export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
item,
|
||||
orientation = "vertical",
|
||||
showText = true,
|
||||
showProgress = true,
|
||||
showWatchedIndicator = true,
|
||||
hasTVPreferredFocus = false,
|
||||
disabled = false,
|
||||
focusableWhenDisabled = false,
|
||||
isCurrent = false,
|
||||
showPlayButton = false,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onFocus: onFocusProp,
|
||||
onBlur: onBlurProp,
|
||||
refSetter,
|
||||
width: customWidth,
|
||||
style,
|
||||
glowColor = "white",
|
||||
scaleAmount = 1.05,
|
||||
imageUrlGetter,
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Determine width based on orientation
|
||||
const width = useMemo(() => {
|
||||
if (customWidth) return customWidth;
|
||||
return orientation === "horizontal"
|
||||
? posterSizes.episode
|
||||
: posterSizes.poster;
|
||||
}, [customWidth, orientation, posterSizes]);
|
||||
|
||||
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
|
||||
|
||||
// Smart image URL selection
|
||||
const imageUrl = useMemo(() => {
|
||||
// Use custom getter if provided
|
||||
if (imageUrlGetter) {
|
||||
return imageUrlGetter(item) ?? null;
|
||||
}
|
||||
|
||||
if (!api) return null;
|
||||
|
||||
// Horizontal orientation: prefer thumbs/backdrops for landscape images
|
||||
if (orientation === "horizontal") {
|
||||
// Episode: prefer episode's own primary image, fall back to parent thumb
|
||||
if (item.Type === "Episode") {
|
||||
// First try episode's own primary image
|
||||
if (item.ImageTags?.Primary) {
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
|
||||
}
|
||||
// Fall back to parent thumb if episode has no image
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
// Last resort: try primary without tag
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}
|
||||
|
||||
// Movie/Series/Program: prefer thumb over primary
|
||||
if (item.ImageTags?.Thumb) {
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags.Thumb}`;
|
||||
}
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}
|
||||
|
||||
// Vertical orientation: use primary image
|
||||
// For episodes, get the series primary image
|
||||
if (
|
||||
item.Type === "Episode" &&
|
||||
item.SeriesId &&
|
||||
item.SeriesPrimaryImageTag
|
||||
) {
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${width * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||
}
|
||||
|
||||
return getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
width: width * 2, // 2x for quality on large screens
|
||||
});
|
||||
}, [api, item, orientation, width, imageUrlGetter]);
|
||||
|
||||
// Progress calculation
|
||||
const progress = useMemo(() => {
|
||||
if (!showProgress) return 0;
|
||||
|
||||
if (item.Type === "Program") {
|
||||
if (!item.StartDate || !item.EndDate) return 0;
|
||||
const startDate = new Date(item.StartDate);
|
||||
const endDate = new Date(item.EndDate);
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
if (total <= 0) return 0;
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
}
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}, [item, showProgress]);
|
||||
|
||||
const isWatched = showWatchedIndicator && item.UserData?.Played === true;
|
||||
|
||||
// Blurhash for placeholder
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.Primary as string;
|
||||
return item.ImageBlurHashes?.Primary?.[key];
|
||||
}, [item]);
|
||||
|
||||
// Glass effect availability
|
||||
const useGlass = isGlassEffectAvailable();
|
||||
|
||||
// Focus animation
|
||||
const animateTo = (value: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: value,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7";
|
||||
|
||||
// Text rendering helpers
|
||||
const renderSubtitle = () => {
|
||||
if (!showText) return null;
|
||||
|
||||
// Episode: S#:E# • duration
|
||||
if (item.Type === "Episode") {
|
||||
const season = item.ParentIndexNumber;
|
||||
const ep = item.IndexNumber;
|
||||
const episodeLabel =
|
||||
season !== undefined && ep !== undefined ? `S${season}:E${ep}` : null;
|
||||
const duration = item.RunTimeTicks
|
||||
? runtimeTicksToMinutes(item.RunTimeTicks)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
{episodeLabel && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#FFFFFF",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{episodeLabel}
|
||||
</Text>
|
||||
)}
|
||||
{duration && (
|
||||
<>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: typography.callout }}>
|
||||
•
|
||||
</Text>
|
||||
<Text style={{ fontSize: typography.callout, color: "#FFFFFF" }}>
|
||||
{duration}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Program: channel name
|
||||
if (item.Type === "Program" && item.ChannelName) {
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{item.ChannelName}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// MusicAlbum: artist
|
||||
if (item.Type === "MusicAlbum") {
|
||||
const artist = item.AlbumArtist || item.Artists?.join(", ");
|
||||
if (artist) {
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{artist}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Audio: artist
|
||||
if (item.Type === "Audio") {
|
||||
const artist = item.Artists?.join(", ") || item.AlbumArtist;
|
||||
if (artist) {
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{artist}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist: track count
|
||||
if (item.Type === "Playlist" && item.ChildCount) {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{item.ChildCount} tracks
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: production year
|
||||
if (item.ProductionYear) {
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Now Playing badge component
|
||||
const NowPlayingBadge = isCurrent ? (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: 12,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
gap: 6,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play' size={16} color='#000000' />
|
||||
<Text
|
||||
style={{
|
||||
color: "#000000",
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
}}
|
||||
>
|
||||
Now Playing
|
||||
</Text>
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
// Play button overlay component
|
||||
const PlayButtonOverlay = showPlayButton ? (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play-circle' size={56} color='white' />
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
// Render poster image
|
||||
const renderPosterImage = () => {
|
||||
// Empty placeholder when no URL
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width,
|
||||
aspectRatio,
|
||||
borderRadius: 24,
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Glass effect rendering (tvOS 26+)
|
||||
if (useGlass) {
|
||||
return (
|
||||
<View style={{ position: "relative" }}>
|
||||
<GlassPosterView
|
||||
imageUrl={imageUrl}
|
||||
aspectRatio={aspectRatio}
|
||||
cornerRadius={24}
|
||||
progress={progress}
|
||||
showWatchedIndicator={isWatched}
|
||||
isFocused={focused}
|
||||
width={width}
|
||||
style={{ width }}
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback rendering for older tvOS versions
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
aspectRatio,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
placeholder={{ blurhash }}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{ uri: imageUrl }}
|
||||
cachePolicy='memory-disk'
|
||||
contentFit='cover'
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
<WatchedIndicator item={item} />
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Render title based on item type
|
||||
const renderTitle = () => {
|
||||
if (!showText) return null;
|
||||
|
||||
// Episode: show episode name as title
|
||||
if (item.Type === "Episode") {
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#FFFFFF",
|
||||
marginTop: 4,
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// MusicArtist: centered text
|
||||
if (item.Type === "MusicArtist") {
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: show name
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: typography.body, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width,
|
||||
opacity: isCurrent
|
||||
? 0.75
|
||||
: disabled && !focusableWhenDisabled
|
||||
? 0.5
|
||||
: 1,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
ref={refSetter}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
// Only animate scale when not using glass effect (glass handles its own focus visual)
|
||||
if (!useGlass) {
|
||||
animateTo(scaleAmount);
|
||||
}
|
||||
onFocusProp?.();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
if (!useGlass) {
|
||||
animateTo(1);
|
||||
}
|
||||
onBlurProp?.();
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled && !focusableWhenDisabled}
|
||||
focusable={!disabled || focusableWhenDisabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
// Only apply scale transform when not using glass effect
|
||||
transform: useGlass ? undefined : [{ scale }],
|
||||
// Only apply shadow glow when not using glass (glass has its own glow)
|
||||
shadowColor: useGlass ? undefined : shadowColor,
|
||||
shadowOffset: useGlass ? undefined : { width: 0, height: 0 },
|
||||
shadowOpacity: useGlass ? undefined : focused ? 0.3 : 0,
|
||||
shadowRadius: useGlass ? undefined : focused ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
{renderPosterImage()}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
{/* Text below poster */}
|
||||
{showText && (
|
||||
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
{renderSubtitle()}
|
||||
{renderTitle()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderTitle()}
|
||||
{renderSubtitle()}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import React from "react";
|
||||
import { Animated, Platform, Pressable, View } from "react-native";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Animated, Easing, Platform, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVSeriesSeasonCardProps {
|
||||
title: string;
|
||||
@@ -16,6 +16,8 @@ export interface TVSeriesSeasonCardProps {
|
||||
imageUrl: string | null;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
/** Setter function for the ref (for focus guide destinations) */
|
||||
refSetter?: (ref: View | null) => void;
|
||||
}
|
||||
|
||||
export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
@@ -24,14 +26,25 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
imageUrl,
|
||||
onPress,
|
||||
hasTVPreferredFocus,
|
||||
refSetter,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
const sizes = useScaledTVSizes();
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
// Check if glass effect is available (tvOS 26+)
|
||||
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
||||
|
||||
// Scale animation for focus (only used when NOT using glass effect)
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
const animateTo = (value: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: value,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
const renderPoster = () => {
|
||||
if (useGlass) {
|
||||
return (
|
||||
@@ -42,8 +55,8 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
progress={0}
|
||||
showWatchedIndicator={false}
|
||||
isFocused={focused}
|
||||
width={210}
|
||||
style={{ width: 210, marginBottom: 14 }}
|
||||
width={sizes.posters.poster}
|
||||
style={{ width: sizes.posters.poster }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -51,14 +64,11 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
width: sizes.posters.poster,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
marginBottom: 14,
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
@@ -83,33 +93,45 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 210,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: useGlass ? 0 : focused ? 0.5 : 0,
|
||||
shadowRadius: useGlass ? 0 : focused ? 20 : 0,
|
||||
},
|
||||
]}
|
||||
<View style={{ width: sizes.posters.poster }}>
|
||||
<Pressable
|
||||
ref={refSetter}
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
// Only animate scale when not using glass effect (glass handles its own focus visual)
|
||||
if (!useGlass) {
|
||||
animateTo(1.05);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
if (!useGlass) {
|
||||
animateTo(1);
|
||||
}
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
{renderPoster()}
|
||||
<Animated.View
|
||||
style={{
|
||||
// Only apply scale transform when not using glass effect
|
||||
transform: useGlass ? undefined : [{ scale }],
|
||||
// Only apply shadow glow when not using glass (glass has its own glow)
|
||||
shadowColor: useGlass ? undefined : "#ffffff",
|
||||
shadowOffset: useGlass ? undefined : { width: 0, height: 0 },
|
||||
shadowOpacity: useGlass ? undefined : focused ? 0.3 : 0,
|
||||
shadowRadius: useGlass ? undefined : focused ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
{renderPoster()}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
textAlign: "center",
|
||||
marginBottom: 4,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
@@ -120,17 +142,15 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.8)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
textAlign: "center",
|
||||
color: "#9CA3AF",
|
||||
marginTop: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user