Files
streamyfin/components/series/TVEpisodeCard.tsx

150 lines
4.5 KiB
TypeScript

import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import React, { useMemo } from "react";
import { View } from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToMinutes } from "@/utils/time";
export const TV_EPISODE_WIDTH = 340;
interface TVEpisodeCardProps {
episode: BaseItemDto;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
onPress: () => void;
onFocus?: () => void;
onBlur?: () => void;
/** Setter function for the ref (for focus guide destinations) */
refSetter?: (ref: View | null) => void;
}
export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
episode,
hasTVPreferredFocus = false,
disabled = false,
onPress,
onFocus,
onBlur,
refSetter,
}) => {
const typography = useScaledTVTypography();
const api = useAtomValue(apiAtom);
const thumbnailUrl = useMemo(() => {
if (!api) return null;
// Try to get episode primary image first
if (episode.ImageTags?.Primary) {
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80&tag=${episode.ImageTags.Primary}`;
}
// Fall back to series thumb or backdrop
if (episode.ParentBackdropItemId && episode.ParentThumbImageTag) {
return `${api.basePath}/Items/${episode.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${episode.ParentThumbImageTag}`;
}
// Default episode image
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80`;
}, [api, episode]);
const duration = useMemo(() => {
if (!episode.RunTimeTicks) return null;
return runtimeTicksToMinutes(episode.RunTimeTicks);
}, [episode.RunTimeTicks]);
const episodeLabel = useMemo(() => {
const season = episode.ParentIndexNumber;
const ep = episode.IndexNumber;
if (season !== undefined && ep !== undefined) {
return `S${season}:E${ep}`;
}
return null;
}, [episode.ParentIndexNumber, episode.IndexNumber]);
return (
<View style={{ width: TV_EPISODE_WIDTH, opacity: disabled ? 0.5 : 1 }}>
<TVFocusablePoster
onPress={onPress}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
onFocus={onFocus}
onBlur={onBlur}
refSetter={refSetter}
>
<View
style={{
width: TV_EPISODE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{thumbnailUrl ? (
<Image
source={{ uri: thumbnailUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#262626",
}}
/>
)}
<WatchedIndicator item={episode} />
<ProgressBar item={episode} />
</View>
</TVFocusablePoster>
{/* Episode info below thumbnail */}
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
<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>
<Text
numberOfLines={2}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
marginTop: 4,
fontWeight: "500",
}}
>
{episode.Name}
</Text>
</View>
</View>
);
};