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 { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { GenreTags } from "@/components/GenreTags"; import { GenreTags } from "@/components/GenreTags";
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; import { TVEpisodeList } from "@/components/series/TVEpisodeList";
import { import {
TVBackdrop, TVBackdrop,
TVButton, TVButton,
@@ -806,31 +806,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{t("item_card.more_from_this_season")} {t("item_card.more_from_this_season")}
</Text> </Text>
<ScrollView <TVEpisodeList
horizontal episodes={seasonEpisodes}
showsHorizontalScrollIndicator={false} currentEpisodeId={item.Id}
style={{ marginHorizontal: -80, overflow: "visible" }} onEpisodePress={handleEpisodePress}
contentContainerStyle={{ firstEpisodeRefSetter={setFirstEpisodeRef}
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>
</View> </View>
)} )}

View File

@@ -10,6 +10,10 @@ import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { WatchedIndicator } from "@/components/WatchedIndicator"; import { WatchedIndicator } from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
@@ -74,6 +78,42 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
return null; return null;
}, [episode.ParentIndexNumber, episode.IndexNumber]); }, [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 ( return (
<View <View
style={{ style={{
@@ -90,63 +130,51 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
onBlur={onBlur} onBlur={onBlur}
refSetter={refSetter} refSetter={refSetter}
> >
<View {useGlass ? (
style={{ <View style={{ position: "relative" }}>
width: posterSizes.episode, <GlassPosterView
aspectRatio: 16 / 9, imageUrl={thumbnailUrl}
borderRadius: 24, aspectRatio={16 / 9}
overflow: "hidden", cornerRadius={24}
backgroundColor: "#1a1a1a", progress={progress}
}} showWatchedIndicator={isWatched}
> isFocused={false}
{thumbnailUrl ? ( width={posterSizes.episode}
<Image style={{ width: posterSizes.episode }}
source={{ uri: thumbnailUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/> />
) : ( {NowPlayingBadge}
<View </View>
style={{ ) : (
width: "100%", <View
height: "100%", style={{
backgroundColor: "#262626", width: posterSizes.episode,
}} aspectRatio: 16 / 9,
/> borderRadius: 24,
)} overflow: "hidden",
<WatchedIndicator item={episode} /> backgroundColor: "#1a1a1a",
<ProgressBar item={episode} /> }}
>
{/* Now Playing badge */} {thumbnailUrl ? (
{isCurrent && ( <Image
<View source={{ uri: thumbnailUrl }}
style={{ style={{ width: "100%", height: "100%" }}
position: "absolute", contentFit='cover'
top: 12, cachePolicy='memory-disk'
left: 12, />
backgroundColor: "#FFFFFF", ) : (
borderRadius: 8, <View
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 8,
gap: 6,
}}
>
<Ionicons name='play' size={16} color='#000000' />
<Text
style={{ style={{
color: "#000000", width: "100%",
fontSize: 14, height: "100%",
fontWeight: "700", backgroundColor: "#262626",
}} }}
> />
Now Playing )}
</Text> <WatchedIndicator item={episode} />
</View> <ProgressBar item={episode} />
)} {NowPlayingBadge}
</View> </View>
)}
</TVFocusablePoster> </TVFocusablePoster>
{/* Episode info below thumbnail */} {/* 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 { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { seasonIndexAtom } from "@/components/series/SeasonPicker"; 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 { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
@@ -46,7 +46,6 @@ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const HORIZONTAL_PADDING = 80; const HORIZONTAL_PADDING = 80;
const TOP_PADDING = 140; const TOP_PADDING = 140;
const POSTER_WIDTH_PERCENT = 0.22; const POSTER_WIDTH_PERCENT = 0.22;
const ITEM_GAP = 16;
const SCALE_PADDING = 20; const SCALE_PADDING = 20;
interface TVSeriesPageProps { interface TVSeriesPageProps {
@@ -619,43 +618,17 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
/> />
)} )}
<ScrollView <TVEpisodeList
ref={episodeListRef} episodes={episodesForSeason}
horizontal disabled={isSeasonModalVisible}
showsHorizontalScrollIndicator={false} onEpisodePress={handleEpisodePress}
style={{ overflow: "visible" }} onFocus={handleEpisodeFocus}
contentContainerStyle={{ onBlur={handleEpisodeBlur}
paddingVertical: SCALE_PADDING, scrollViewRef={episodeListRef}
paddingHorizontal: SCALE_PADDING, firstEpisodeRefSetter={setFirstEpisodeRef}
gap: ITEM_GAP, emptyText={t("item_card.no_episodes_for_this_season")}
}} horizontalPadding={HORIZONTAL_PADDING}
> />
{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>
</View> </View>
</ScrollView> </ScrollView>
</View> </View>