feat(tv): add season episode list to episode detail page

This commit is contained in:
Fredrik Burmester
2026-01-25 23:23:03 +01:00
parent 36d6686258
commit 715764cef8
2 changed files with 104 additions and 3 deletions

View File

@@ -4,8 +4,8 @@ import type {
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQueryClient } from "@tanstack/react-query";
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { useAtom } from "jotai";
@@ -22,7 +22,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
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 {
TVBackdrop,
TVButton,
@@ -71,7 +73,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
({ item, itemWithSources }) => {
const typography = useScaledTVTypography();
const [api] = useAtom(apiAtom);
const [_user] = useAtom(userAtom);
const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
@@ -81,6 +83,31 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const _itemColors = useImageColorsReturn({ item });
// State for first episode card ref (used for focus guide)
const [firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
// Fetch season episodes for episodes
const { data: seasonEpisodes = [] } = useQuery({
queryKey: ["episodes", item?.SeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId,
userId: user.Id,
seasonId: item.SeasonId,
enableUserData: true,
fields: ["MediaSources", "Overview"],
});
return res.data.Items || [];
},
enabled:
!!api &&
!!user?.Id &&
!!item?.SeriesId &&
!!item?.SeasonId &&
item?.Type === "Episode",
});
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
@@ -440,6 +467,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
const handleEpisodePress = useCallback(
(episode: BaseItemDto) => {
const navigation = getItemNavigation(episode, "(home)");
router.push(navigation as any);
},
[router],
);
if (!item || !selectedOptions) return null;
return (
@@ -792,6 +827,71 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
/>
)}
{/* Focus guide: cast → episodes (downward navigation) */}
{showVisualCast && firstEpisodeRef && (
<TVFocusGuideView
destinations={[firstEpisodeRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Season Episodes - Episode only */}
{item.Type === "Episode" && seasonEpisodes.length > 1 && (
<View style={{ marginBottom: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 24,
}}
>
{t("item_card.more_from_this_season")}
</Text>
{/* Focus guides - stacked together above the list */}
{/* Downward: options → first episode (only when no cast section) */}
{!showVisualCast && firstEpisodeRef && (
<TVFocusGuideView
destinations={[firstEpisodeRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Upward: episodes → cast (first actor) or options (last button) */}
{(firstActorCardRef || lastOptionButtonRef) && (
<TVFocusGuideView
destinations={
[firstActorCardRef ?? lastOptionButtonRef].filter(
Boolean,
) as View[]
}
style={{ height: 1, width: "100%" }}
/>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -80, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 12,
gap: 24,
}}
>
{seasonEpisodes.map((episode, index) => (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => handleEpisodePress(episode)}
disabled={episode.Id === item.Id}
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
/>
))}
</ScrollView>
</View>
)}
{/* From this Series - Episode only */}
<TVSeriesNavigation
item={item}

View File

@@ -667,6 +667,7 @@
"seasons": "Seasons",
"season": "Season",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",