From 111397a306164f1d52005f3eb06fdb23948bc929 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:17:29 +0100 Subject: [PATCH] refactor(tv): extract TVEpisodeList component to reduce code duplication --- components/ItemContent.tv.tsx | 33 ++----- components/series/TVEpisodeCard.tsx | 136 +++++++++++++++++----------- components/series/TVEpisodeList.tsx | 93 +++++++++++++++++++ components/series/TVSeriesPage.tsx | 51 +++-------- 4 files changed, 194 insertions(+), 119 deletions(-) create mode 100644 components/series/TVEpisodeList.tsx diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 7caffb0b..21738e97 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -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 = React.memo( {t("item_card.more_from_this_season")} - - {seasonEpisodes.map((episode, index) => { - const isCurrentEpisode = episode.Id === item.Id; - return ( - handleEpisodePress(episode)} - disabled={isCurrentEpisode} - focusableWhenDisabled={isCurrentEpisode} - isCurrent={isCurrentEpisode} - refSetter={index === 0 ? setFirstEpisodeRef : undefined} - /> - ); - })} - + )} diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx index 60039346..262dc323 100644 --- a/components/series/TVEpisodeCard.tsx +++ b/components/series/TVEpisodeCard.tsx @@ -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 = ({ 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 ? ( + + + + Now Playing + + + ) : null; + return ( = ({ onBlur={onBlur} refSetter={refSetter} > - - {thumbnailUrl ? ( - + - ) : ( - - )} - - - - {/* Now Playing badge */} - {isCurrent && ( - - - + ) : ( + + {thumbnailUrl ? ( + + ) : ( + - Now Playing - - - )} - + /> + )} + + + {NowPlayingBadge} + + )} {/* Episode info below thumbnail */} diff --git a/components/series/TVEpisodeList.tsx b/components/series/TVEpisodeList.tsx new file mode 100644 index 00000000..bb32f7c3 --- /dev/null +++ b/components/series/TVEpisodeList.tsx @@ -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; + /** 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 = ({ + episodes, + currentEpisodeId, + disabled = false, + onEpisodePress, + onFocus, + onBlur, + scrollViewRef, + firstEpisodeRefSetter, + emptyText, + horizontalPadding = 80, +}) => { + const typography = useScaledTVTypography(); + + if (episodes.length === 0 && emptyText) { + return ( + + {emptyText} + + ); + } + + return ( + } + 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 ( + onEpisodePress(episode)} + onFocus={onFocus} + onBlur={onBlur} + disabled={isCurrent || disabled} + focusableWhenDisabled={isCurrent} + isCurrent={isCurrent} + refSetter={index === 0 ? firstEpisodeRefSetter : undefined} + /> + ); + })} + + ); +}; diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index 64830da5..72d26c44 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -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 = ({ /> )} - - {episodesForSeason.length > 0 ? ( - episodesForSeason.map((episode, index) => ( - 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} - /> - )) - ) : ( - - {t("item_card.no_episodes_for_this_season")} - - )} - +