mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-15 18:30:23 +01: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 { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import React from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { Animated, Platform, Pressable, View } from "react-native";
|
import { Animated, Easing, Platform, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import {
|
import {
|
||||||
GlassPosterView,
|
GlassPosterView,
|
||||||
isGlassEffectAvailable,
|
isGlassEffectAvailable,
|
||||||
} from "@/modules/glass-poster";
|
} from "@/modules/glass-poster";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
|
||||||
|
|
||||||
export interface TVSeriesSeasonCardProps {
|
export interface TVSeriesSeasonCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -16,6 +16,8 @@ export interface TVSeriesSeasonCardProps {
|
|||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
hasTVPreferredFocus?: boolean;
|
hasTVPreferredFocus?: boolean;
|
||||||
|
/** Setter function for the ref (for focus guide destinations) */
|
||||||
|
refSetter?: (ref: View | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||||
@@ -24,14 +26,25 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
onPress,
|
onPress,
|
||||||
hasTVPreferredFocus,
|
hasTVPreferredFocus,
|
||||||
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const sizes = useScaledTVSizes();
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
// Check if glass effect is available (tvOS 26+)
|
// Check if glass effect is available (tvOS 26+)
|
||||||
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
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 = () => {
|
const renderPoster = () => {
|
||||||
if (useGlass) {
|
if (useGlass) {
|
||||||
return (
|
return (
|
||||||
@@ -42,8 +55,8 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
progress={0}
|
progress={0}
|
||||||
showWatchedIndicator={false}
|
showWatchedIndicator={false}
|
||||||
isFocused={focused}
|
isFocused={focused}
|
||||||
width={210}
|
width={sizes.posters.poster}
|
||||||
style={{ width: 210, marginBottom: 14 }}
|
style={{ width: sizes.posters.poster }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,14 +64,11 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 210,
|
width: sizes.posters.poster,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
marginBottom: 14,
|
|
||||||
borderWidth: focused ? 3 : 0,
|
|
||||||
borderColor: "#fff",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
@@ -83,33 +93,45 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<View style={{ width: sizes.posters.poster }}>
|
||||||
onPress={onPress}
|
<Pressable
|
||||||
onFocus={handleFocus}
|
ref={refSetter}
|
||||||
onBlur={handleBlur}
|
onPress={onPress}
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
onFocus={() => {
|
||||||
>
|
setFocused(true);
|
||||||
<Animated.View
|
// Only animate scale when not using glass effect (glass handles its own focus visual)
|
||||||
style={[
|
if (!useGlass) {
|
||||||
animatedStyle,
|
animateTo(1.05);
|
||||||
{
|
}
|
||||||
width: 210,
|
}}
|
||||||
shadowColor: "#fff",
|
onBlur={() => {
|
||||||
shadowOffset: { width: 0, height: 0 },
|
setFocused(false);
|
||||||
shadowOpacity: useGlass ? 0 : focused ? 0.5 : 0,
|
if (!useGlass) {
|
||||||
shadowRadius: useGlass ? 0 : focused ? 20 : 0,
|
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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: typography.body,
|
||||||
fontWeight: "600",
|
color: "#FFFFFF",
|
||||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
|
||||||
textAlign: "center",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
@@ -120,17 +142,15 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: focused
|
color: "#9CA3AF",
|
||||||
? "rgba(255,255,255,0.8)"
|
marginTop: 4,
|
||||||
: "rgba(255,255,255,0.5)",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</View>
|
||||||
</Pressable>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user