mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-15 18:30:23 +01: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 { 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user