From 0cd74519d444260c10fc0b0f095d2172faffe4ba Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Jan 2026 09:15:01 +0100 Subject: [PATCH] fix(tv): correct episode image priority and scale animation in posters --- components/tv/TVPosterCard.tsx | 575 +++++++++++++++++++++++++++ components/tv/TVSeriesSeasonCard.tsx | 98 +++-- 2 files changed, 634 insertions(+), 39 deletions(-) create mode 100644 components/tv/TVPosterCard.tsx diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx new file mode 100644 index 00000000..d27bc4f6 --- /dev/null +++ b/components/tv/TVPosterCard.tsx @@ -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 = ({ + 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 ( + + {episodeLabel && ( + + {episodeLabel} + + )} + {duration && ( + <> + + • + + + {duration} + + + )} + + ); + } + + // Program: channel name + if (item.Type === "Program" && item.ChannelName) { + return ( + + {item.ChannelName} + + ); + } + + // MusicAlbum: artist + if (item.Type === "MusicAlbum") { + const artist = item.AlbumArtist || item.Artists?.join(", "); + if (artist) { + return ( + + {artist} + + ); + } + } + + // Audio: artist + if (item.Type === "Audio") { + const artist = item.Artists?.join(", ") || item.AlbumArtist; + if (artist) { + return ( + + {artist} + + ); + } + } + + // Playlist: track count + if (item.Type === "Playlist" && item.ChildCount) { + return ( + + {item.ChildCount} tracks + + ); + } + + // Default: production year + if (item.ProductionYear) { + return ( + + {item.ProductionYear} + + ); + } + + return null; + }; + + // Now Playing badge component + const NowPlayingBadge = isCurrent ? ( + + + + Now Playing + + + ) : null; + + // Play button overlay component + const PlayButtonOverlay = showPlayButton ? ( + + + + ) : null; + + // Render poster image + const renderPosterImage = () => { + // Empty placeholder when no URL + if (!imageUrl) { + return ( + + ); + } + + // Glass effect rendering (tvOS 26+) + if (useGlass) { + return ( + + + {PlayButtonOverlay} + {NowPlayingBadge} + + ); + } + + // Fallback rendering for older tvOS versions + return ( + + + {PlayButtonOverlay} + {NowPlayingBadge} + + + + ); + }; + + // Render title based on item type + const renderTitle = () => { + if (!showText) return null; + + // Episode: show episode name as title + if (item.Type === "Episode") { + return ( + + {item.Name} + + ); + } + + // MusicArtist: centered text + if (item.Type === "MusicArtist") { + return ( + + {item.Name} + + ); + } + + // Default: show name + return ( + + {item.Name} + + ); + }; + + return ( + + { + 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} + > + + {renderPosterImage()} + + + + {/* Text below poster */} + {showText && ( + + {item.Type === "Episode" ? ( + <> + {renderSubtitle()} + {renderTitle()} + + ) : ( + <> + {renderTitle()} + {renderSubtitle()} + + )} + + )} + + ); +}; diff --git a/components/tv/TVSeriesSeasonCard.tsx b/components/tv/TVSeriesSeasonCard.tsx index 6866755c..16a5aef9 100644 --- a/components/tv/TVSeriesSeasonCard.tsx +++ b/components/tv/TVSeriesSeasonCard.tsx @@ -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 = ({ @@ -24,14 +26,25 @@ export const TVSeriesSeasonCard: React.FC = ({ 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 = ({ 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 = ({ return ( {imageUrl ? ( @@ -83,33 +93,45 @@ export const TVSeriesSeasonCard: React.FC = ({ }; return ( - - + { + 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()} + + {renderPoster()} + + + @@ -120,17 +142,15 @@ export const TVSeriesSeasonCard: React.FC = ({ {subtitle} )} - - + + ); };