fix(tv): correct episode image priority and scale animation in posters

This commit is contained in:
Fredrik Burmester
2026-01-30 09:15:01 +01:00
parent 8ecb7c205b
commit 0cd74519d4
2 changed files with 634 additions and 39 deletions

View 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>
);
};

View File

@@ -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>
);
};