mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-28 22:18:14 +00:00
refactor(tv): extract TVEpisodeList component to reduce code duplication
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
93
components/series/TVEpisodeList.tsx
Normal file
93
components/series/TVEpisodeList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user