refactor(tv): extract TVEpisodeList component to reduce code duplication

This commit is contained in:
Fredrik Burmester
2026-01-26 19:17:29 +01:00
parent b79b343ce3
commit 111397a306
4 changed files with 194 additions and 119 deletions

View File

@@ -24,7 +24,7 @@ import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { GenreTags } from "@/components/GenreTags";
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
import {
TVBackdrop,
TVButton,
@@ -806,31 +806,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{t("item_card.more_from_this_season")}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -80, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 12,
gap: 24,
}}
>
{seasonEpisodes.map((episode, index) => {
const isCurrentEpisode = episode.Id === item.Id;
return (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => handleEpisodePress(episode)}
disabled={isCurrentEpisode}
focusableWhenDisabled={isCurrentEpisode}
isCurrent={isCurrentEpisode}
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
/>
);
})}
</ScrollView>
<TVEpisodeList
episodes={seasonEpisodes}
currentEpisodeId={item.Id}
onEpisodePress={handleEpisodePress}
firstEpisodeRefSetter={setFirstEpisodeRef}
/>
</View>
)}

View File

@@ -10,6 +10,10 @@ import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
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 { runtimeTicksToMinutes } from "@/utils/time";
@@ -74,6 +78,42 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
return null;
}, [episode.ParentIndexNumber, episode.IndexNumber]);
const progress = episode.UserData?.PlayedPercentage || 0;
const isWatched = episode.UserData?.Played === true;
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
// Now Playing badge component (shared between glass and fallback)
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;
return (
<View
style={{
@@ -90,63 +130,51 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
onBlur={onBlur}
refSetter={refSetter}
>
<View
style={{
width: posterSizes.episode,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{thumbnailUrl ? (
<Image
source={{ uri: thumbnailUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
{useGlass ? (
<View style={{ position: "relative" }}>
<GlassPosterView
imageUrl={thumbnailUrl}
aspectRatio={16 / 9}
cornerRadius={24}
progress={progress}
showWatchedIndicator={isWatched}
isFocused={false}
width={posterSizes.episode}
style={{ width: posterSizes.episode }}
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#262626",
}}
/>
)}
<WatchedIndicator item={episode} />
<ProgressBar item={episode} />
{/* Now Playing badge */}
{isCurrent && (
<View
style={{
position: "absolute",
top: 12,
left: 12,
backgroundColor: "#FFFFFF",
borderRadius: 8,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 8,
gap: 6,
}}
>
<Ionicons name='play' size={16} color='#000000' />
<Text
{NowPlayingBadge}
</View>
) : (
<View
style={{
width: posterSizes.episode,
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={{
color: "#000000",
fontSize: 14,
fontWeight: "700",
width: "100%",
height: "100%",
backgroundColor: "#262626",
}}
>
Now Playing
</Text>
</View>
)}
</View>
/>
)}
<WatchedIndicator item={episode} />
<ProgressBar item={episode} />
{NowPlayingBadge}
</View>
)}
</TVFocusablePoster>
{/* Episode info below thumbnail */}

View File

@@ -0,0 +1,93 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
import { useScaledTVTypography } from "@/constants/TVTypography";
const LIST_GAP = 24;
const VERTICAL_PADDING = 12;
interface TVEpisodeListProps {
episodes: BaseItemDto[];
/** Shows "Now Playing" badge on the episode matching this ID */
currentEpisodeId?: string;
/** Disable all cards (e.g., when modal is open) */
disabled?: boolean;
/** Handler when an episode is pressed */
onEpisodePress: (episode: BaseItemDto) => void;
/** Called when any episode gains focus */
onFocus?: () => void;
/** Called when any episode loses focus */
onBlur?: () => void;
/** Ref for programmatic scrolling */
scrollViewRef?: React.RefObject<ScrollView | null>;
/** Setter for the first episode ref (for focus guide destinations) */
firstEpisodeRefSetter?: (ref: View | null) => void;
/** Text to show when episodes array is empty */
emptyText?: string;
/** Horizontal padding for the list content (default: 80) */
horizontalPadding?: number;
}
export const TVEpisodeList: React.FC<TVEpisodeListProps> = ({
episodes,
currentEpisodeId,
disabled = false,
onEpisodePress,
onFocus,
onBlur,
scrollViewRef,
firstEpisodeRefSetter,
emptyText,
horizontalPadding = 80,
}) => {
const typography = useScaledTVTypography();
if (episodes.length === 0 && emptyText) {
return (
<Text
style={{
color: "#737373",
fontSize: typography.callout,
marginLeft: 20,
}}
>
{emptyText}
</Text>
);
}
return (
<ScrollView
ref={scrollViewRef as React.RefObject<ScrollView>}
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -horizontalPadding, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: horizontalPadding,
paddingVertical: VERTICAL_PADDING,
gap: LIST_GAP,
}}
>
{episodes.map((episode, index) => {
const isCurrent = currentEpisodeId
? episode.Id === currentEpisodeId
: false;
return (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => onEpisodePress(episode)}
onFocus={onFocus}
onBlur={onBlur}
disabled={isCurrent || disabled}
focusableWhenDisabled={isCurrent}
isCurrent={isCurrent}
refSetter={index === 0 ? firstEpisodeRefSetter : undefined}
/>
);
})}
</ScrollView>
);
};

View File

@@ -27,7 +27,7 @@ import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { seasonIndexAtom } from "@/components/series/SeasonPicker";
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
@@ -46,7 +46,6 @@ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const HORIZONTAL_PADDING = 80;
const TOP_PADDING = 140;
const POSTER_WIDTH_PERCENT = 0.22;
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
interface TVSeriesPageProps {
@@ -619,43 +618,17 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
/>
)}
<ScrollView
ref={episodeListRef}
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
gap: ITEM_GAP,
}}
>
{episodesForSeason.length > 0 ? (
episodesForSeason.map((episode, index) => (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => handleEpisodePress(episode)}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
disabled={isSeasonModalVisible}
// Pass refSetter to first episode for focus guide destination
// Note: Do NOT use hasTVPreferredFocus on focus guide destinations
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
/>
))
) : (
<Text
style={{
color: "#737373",
fontSize: typography.callout,
marginLeft: SCALE_PADDING,
}}
>
{t("item_card.no_episodes_for_this_season")}
</Text>
)}
</ScrollView>
<TVEpisodeList
episodes={episodesForSeason}
disabled={isSeasonModalVisible}
onEpisodePress={handleEpisodePress}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
scrollViewRef={episodeListRef}
firstEpisodeRefSetter={setFirstEpisodeRef}
emptyText={t("item_card.no_episodes_for_this_season")}
horizontalPadding={HORIZONTAL_PADDING}
/>
</View>
</ScrollView>
</View>