diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx
index 17fec0be..e3083a49 100644
--- a/components/ItemContent.tv.tsx
+++ b/components/ItemContent.tv.tsx
@@ -39,6 +39,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
+import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -538,6 +539,230 @@ const TVOptionCard = React.forwardRef<
);
});
+// Circular actor card with Apple TV style focus animations
+const TVActorCard: React.FC<{
+ person: {
+ Id?: string | null;
+ Name?: string | null;
+ Role?: string | null;
+ };
+ apiBasePath?: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+}> = ({ person, apiBasePath, onPress, hasTVPreferredFocus }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ const imageUrl = person.Id
+ ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90`
+ : null;
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.08);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+ {/* Circular image */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Name */}
+
+ {person.Name}
+
+
+ {/* Role */}
+ {person.Role && (
+
+ {person.Role}
+
+ )}
+
+
+ );
+};
+
+// Series/Season poster card with Apple TV style focus animations
+const TVSeriesSeasonCard: React.FC<{
+ title: string;
+ subtitle?: string;
+ imageUrl: string | null;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+}> = ({ title, subtitle, imageUrl, onPress, hasTVPreferredFocus }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+ {/* Poster image */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Subtitle */}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ );
+};
+
// Button to open option selector
const TVOptionButton: React.FC<{
label: string;
@@ -853,9 +1078,29 @@ export const ItemContentTV: React.FC = React.memo(
// Get director
const director = item?.People?.find((p) => p.Type === "Director");
- // Get cast (first 3)
+ // Get cast (first 3 for text display)
const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3);
+ // Get full cast for visual display (up to 10 actors)
+ const fullCast = useMemo(() => {
+ return (
+ item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? []
+ );
+ }, [item?.People]);
+
+ // Series/Season image URLs for episodes
+ const seriesImageUrl = useMemo(() => {
+ if (item?.Type !== "Episode" || !item.SeriesId) return null;
+ return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 });
+ }, [api, item?.Type, item?.SeriesId]);
+
+ const seasonImageUrl = useMemo(() => {
+ if (item?.Type !== "Episode") return null;
+ const seasonId = item.SeasonId || item.ParentId;
+ if (!seasonId) return null;
+ return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
+ }, [api, item?.Type, item?.SeasonId, item?.ParentId]);
+
if (!item || !selectedOptions) return null;
return (
@@ -1292,6 +1537,101 @@ export const ItemContentTV: React.FC = React.memo(
)}
+
+ {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
+ {(item.Type === "Movie" ||
+ item.Type === "Series" ||
+ item.Type === "Episode") &&
+ fullCast.length > 0 && (
+
+
+ {t("item_card.cast")}
+
+
+ {fullCast.map((person, index) => (
+ {
+ if (person.Id) {
+ router.push(`/(auth)/persons/${person.Id}`);
+ }
+ }}
+ />
+ ))}
+
+
+ )}
+
+ {/* From this Series - Episode only */}
+ {item.Type === "Episode" && item.SeriesId && (
+
+
+ {t("item_card.from_this_series") || "From this Series"}
+
+
+ {/* Series card */}
+ {
+ router.push(`/(auth)/series/${item.SeriesId}`);
+ }}
+ hasTVPreferredFocus={false}
+ />
+
+ {/* Season card */}
+ {(item.SeasonId || item.ParentId) && (
+ {
+ router.push(
+ `/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`,
+ );
+ }}
+ />
+ )}
+
+
+ )}