mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-21 08:46:27 +00:00
636 lines
18 KiB
TypeScript
636 lines
18 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import { useSegments } from "expo-router";
|
|
import { useAtom, useAtomValue } from "jotai";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Animated,
|
|
Dimensions,
|
|
Easing,
|
|
Pressable,
|
|
ScrollView,
|
|
TVFocusGuideView,
|
|
View,
|
|
} from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
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 { TVEpisodeList } from "@/components/series/TVEpisodeList";
|
|
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
|
|
import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton";
|
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
|
import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
|
|
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
|
|
import { useDownload } from "@/providers/DownloadProvider";
|
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
|
import {
|
|
buildOfflineSeasons,
|
|
getDownloadedEpisodesForSeason,
|
|
} from "@/utils/downloads/offline-series";
|
|
|
|
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 SCALE_PADDING = 20;
|
|
|
|
interface TVSeriesPageProps {
|
|
item: BaseItemDto;
|
|
allEpisodes?: BaseItemDto[];
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
// Focusable button component for TV
|
|
const TVFocusableButton: React.FC<{
|
|
onPress: () => void;
|
|
children: React.ReactNode;
|
|
hasTVPreferredFocus?: boolean;
|
|
disabled?: boolean;
|
|
variant?: "primary" | "secondary";
|
|
refSetter?: (ref: View | null) => void;
|
|
}> = ({
|
|
onPress,
|
|
children,
|
|
hasTVPreferredFocus,
|
|
disabled = false,
|
|
variant = "primary",
|
|
refSetter,
|
|
}) => {
|
|
const [focused, setFocused] = useState(false);
|
|
const scale = useRef(new Animated.Value(1)).current;
|
|
|
|
const animateTo = (v: number) =>
|
|
Animated.timing(scale, {
|
|
toValue: v,
|
|
duration: 150,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}).start();
|
|
|
|
const isPrimary = variant === "primary";
|
|
|
|
return (
|
|
<Pressable
|
|
ref={refSetter}
|
|
onPress={onPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.05);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
animateTo(1);
|
|
}}
|
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
|
disabled={disabled}
|
|
focusable={!disabled}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
transform: [{ scale }],
|
|
shadowColor: isPrimary ? "#fff" : "#a855f7",
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: focused ? 0.6 : 0,
|
|
shadowRadius: focused ? 20 : 0,
|
|
},
|
|
]}
|
|
>
|
|
<View
|
|
style={{
|
|
backgroundColor: focused
|
|
? isPrimary
|
|
? "#ffffff"
|
|
: "#7c3aed"
|
|
: isPrimary
|
|
? "rgba(255, 255, 255, 0.9)"
|
|
: "rgba(124, 58, 237, 0.8)",
|
|
borderRadius: 12,
|
|
paddingVertical: 18,
|
|
paddingHorizontal: 32,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
minWidth: 180,
|
|
}}
|
|
>
|
|
{children}
|
|
</View>
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
// Season selector button
|
|
const TVSeasonButton: React.FC<{
|
|
seasonName: string;
|
|
onPress: () => void;
|
|
disabled?: boolean;
|
|
}> = ({ seasonName, onPress, disabled = false }) => {
|
|
const typography = useScaledTVTypography();
|
|
const [focused, setFocused] = useState(false);
|
|
const scale = useRef(new Animated.Value(1)).current;
|
|
|
|
const animateTo = (v: number) =>
|
|
Animated.timing(scale, {
|
|
toValue: v,
|
|
duration: 150,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}).start();
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.05);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
animateTo(1);
|
|
}}
|
|
disabled={disabled}
|
|
focusable={!disabled}
|
|
>
|
|
<Animated.View
|
|
style={{
|
|
transform: [{ scale }],
|
|
shadowColor: "#fff",
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: focused ? 0.6 : 0,
|
|
shadowRadius: focused ? 20 : 0,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
|
borderRadius: 12,
|
|
paddingVertical: 18,
|
|
paddingHorizontal: 32,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.body,
|
|
color: focused ? "#000" : "#FFFFFF",
|
|
fontWeight: "bold",
|
|
}}
|
|
>
|
|
{seasonName}
|
|
</Text>
|
|
<Ionicons
|
|
name='chevron-down'
|
|
size={28}
|
|
color={focused ? "#000" : "#FFFFFF"}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|
item,
|
|
allEpisodes = [],
|
|
isLoading: _isLoading,
|
|
}) => {
|
|
const typography = useScaledTVTypography();
|
|
const { t } = useTranslation();
|
|
const insets = useSafeAreaInsets();
|
|
const router = useRouter();
|
|
const segments = useSegments();
|
|
const from = (segments as string[])[2] || "(home)";
|
|
const isOffline = useOfflineMode();
|
|
const [api] = useAtom(apiAtom);
|
|
const [user] = useAtom(userAtom);
|
|
const { getDownloadedItems, downloadedItems } = useDownload();
|
|
const { showSeasonModal } = useTVSeriesSeasonModal();
|
|
const { showItemActions } = useTVItemActionModal();
|
|
const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom);
|
|
const isSeasonModalVisible = seasonModalState !== null;
|
|
|
|
// Auto-play theme music (handles fade in/out and cleanup)
|
|
useTVThemeMusic(item.Id);
|
|
|
|
// Season state
|
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
|
const selectedSeasonIndex = useMemo(
|
|
() => seasonIndexState[item.Id ?? ""] ?? 1,
|
|
[item.Id, seasonIndexState],
|
|
);
|
|
|
|
// Focus guide refs (using useState to trigger re-renders when refs are set)
|
|
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
|
|
const [firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
|
|
|
|
// ScrollView ref for page scrolling
|
|
const mainScrollRef = useRef<ScrollView>(null);
|
|
// ScrollView ref for scrolling back
|
|
const episodeListRef = useRef<ScrollView>(null);
|
|
const [focusedCount, setFocusedCount] = useState(0);
|
|
const prevFocusedCount = useRef(0);
|
|
|
|
// Track focus count for episode list
|
|
useEffect(() => {
|
|
prevFocusedCount.current = focusedCount;
|
|
}, [focusedCount]);
|
|
|
|
const handleEpisodeFocus = useCallback(() => {
|
|
setFocusedCount((c) => c + 1);
|
|
}, []);
|
|
|
|
const handleEpisodeBlur = useCallback(() => {
|
|
setFocusedCount((c) => Math.max(0, c - 1));
|
|
}, []);
|
|
|
|
// Fetch seasons
|
|
const { data: seasons = [] } = useQuery({
|
|
queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
|
|
queryFn: async () => {
|
|
if (isOffline) {
|
|
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
|
|
}
|
|
if (!api || !user?.Id || !item.Id) return [];
|
|
|
|
const response = await api.axiosInstance.get(
|
|
`${api.basePath}/Shows/${item.Id}/Seasons`,
|
|
{
|
|
params: {
|
|
userId: user.Id,
|
|
itemId: item.Id,
|
|
Fields: "ItemCounts,PrimaryImageAspectRatio",
|
|
},
|
|
headers: {
|
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
},
|
|
},
|
|
);
|
|
return response.data.Items || [];
|
|
},
|
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
|
refetchInterval: !isOffline ? 60 * 1000 : undefined,
|
|
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
|
|
});
|
|
|
|
// Get selected season ID
|
|
const selectedSeasonId = useMemo(() => {
|
|
const season = seasons.find(
|
|
(s: BaseItemDto) =>
|
|
s.IndexNumber === selectedSeasonIndex ||
|
|
s.Name === String(selectedSeasonIndex),
|
|
);
|
|
return season?.Id ?? null;
|
|
}, [seasons, selectedSeasonIndex]);
|
|
|
|
// Get selected season number for offline mode
|
|
const selectedSeasonNumber = useMemo(() => {
|
|
if (!isOffline) return null;
|
|
const season = seasons.find(
|
|
(s: BaseItemDto) =>
|
|
s.IndexNumber === selectedSeasonIndex ||
|
|
s.Name === String(selectedSeasonIndex),
|
|
);
|
|
return season?.IndexNumber ?? null;
|
|
}, [isOffline, seasons, selectedSeasonIndex]);
|
|
|
|
// Fetch episodes for selected season
|
|
const { data: episodesForSeason = [] } = useQuery({
|
|
queryKey: [
|
|
"episodes",
|
|
item.Id,
|
|
isOffline ? selectedSeasonNumber : selectedSeasonId,
|
|
isOffline,
|
|
downloadedItems.length,
|
|
],
|
|
queryFn: async () => {
|
|
if (isOffline) {
|
|
return getDownloadedEpisodesForSeason(
|
|
getDownloadedItems(),
|
|
item.Id!,
|
|
selectedSeasonNumber!,
|
|
);
|
|
}
|
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
|
|
|
const res = await getTvShowsApi(api).getEpisodes({
|
|
seriesId: item.Id,
|
|
userId: user.Id,
|
|
seasonId: selectedSeasonId,
|
|
enableUserData: true,
|
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
|
});
|
|
return res.data.Items || [];
|
|
},
|
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
|
refetchInterval: !isOffline ? 60 * 1000 : undefined,
|
|
enabled: isOffline
|
|
? !!item.Id && selectedSeasonNumber !== null
|
|
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
|
});
|
|
|
|
// Find next unwatched episode
|
|
const nextUnwatchedEpisode = useMemo(() => {
|
|
// First check all episodes for a "next up" candidate
|
|
for (const ep of allEpisodes) {
|
|
if (!ep.UserData?.Played) {
|
|
// Check if it has progress (continue watching)
|
|
if ((ep.UserData?.PlaybackPositionTicks ?? 0) > 0) {
|
|
return ep;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find first unwatched
|
|
return allEpisodes.find((ep) => !ep.UserData?.Played) || allEpisodes[0];
|
|
}, [allEpisodes]);
|
|
|
|
// Get season name for button
|
|
const selectedSeasonName = useMemo(() => {
|
|
const season = seasons.find(
|
|
(s: BaseItemDto) =>
|
|
s.IndexNumber === selectedSeasonIndex ||
|
|
s.Name === String(selectedSeasonIndex),
|
|
);
|
|
return season?.Name || `Season ${selectedSeasonIndex}`;
|
|
}, [seasons, selectedSeasonIndex]);
|
|
|
|
// Handle episode press
|
|
const handleEpisodePress = useCallback(
|
|
(episode: BaseItemDto) => {
|
|
const navigation = getItemNavigation(episode, from);
|
|
router.push(navigation as any);
|
|
},
|
|
[from, router],
|
|
);
|
|
|
|
// Handle play next episode
|
|
const handlePlayNextEpisode = useCallback(() => {
|
|
if (nextUnwatchedEpisode) {
|
|
const navigation = getItemNavigation(nextUnwatchedEpisode, from);
|
|
router.push(navigation as any);
|
|
}
|
|
}, [nextUnwatchedEpisode, from, router]);
|
|
|
|
// Handle season selection
|
|
const handleSeasonSelect = useCallback(
|
|
(seasonIdx: number) => {
|
|
if (!item.Id) return;
|
|
setSeasonIndexState((prev) => ({
|
|
...prev,
|
|
[item.Id!]: seasonIdx,
|
|
}));
|
|
},
|
|
[item.Id, setSeasonIndexState],
|
|
);
|
|
|
|
// Season options for the modal
|
|
const seasonOptions = useMemo(() => {
|
|
return seasons.map((season: BaseItemDto) => ({
|
|
label: season.Name || `Season ${season.IndexNumber}`,
|
|
value: season.IndexNumber ?? 0,
|
|
selected:
|
|
season.IndexNumber === selectedSeasonIndex ||
|
|
season.Name === String(selectedSeasonIndex),
|
|
}));
|
|
}, [seasons, selectedSeasonIndex]);
|
|
|
|
// Open season modal
|
|
const handleOpenSeasonModal = useCallback(() => {
|
|
if (!item.Id) return;
|
|
showSeasonModal({
|
|
seasons: seasonOptions,
|
|
selectedSeasonIndex,
|
|
itemId: item.Id,
|
|
onSeasonSelect: handleSeasonSelect,
|
|
});
|
|
}, [
|
|
item.Id,
|
|
seasonOptions,
|
|
selectedSeasonIndex,
|
|
handleSeasonSelect,
|
|
showSeasonModal,
|
|
]);
|
|
|
|
// Get play button text
|
|
const playButtonText = useMemo(() => {
|
|
if (!nextUnwatchedEpisode) return t("common.play");
|
|
|
|
const season = nextUnwatchedEpisode.ParentIndexNumber;
|
|
const episode = nextUnwatchedEpisode.IndexNumber;
|
|
const hasProgress =
|
|
(nextUnwatchedEpisode.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
|
|
|
if (hasProgress) {
|
|
return `${t("home.continue")} S${season}:E${episode}`;
|
|
}
|
|
return `${t("common.play")} S${season}:E${episode}`;
|
|
}, [nextUnwatchedEpisode, t]);
|
|
|
|
if (!item) return null;
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
|
{/* Full-screen backdrop */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
}}
|
|
>
|
|
<ItemImage
|
|
variant='Backdrop'
|
|
item={item}
|
|
style={{ width: "100%", height: "100%" }}
|
|
/>
|
|
{/* Gradient overlays for readability */}
|
|
<LinearGradient
|
|
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
|
locations={[0, 0.5, 1]}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
height: "70%",
|
|
}}
|
|
/>
|
|
<LinearGradient
|
|
colors={["rgba(0,0,0,0.8)", "transparent"]}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 0.6, y: 0 }}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: "60%",
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
{/* Main content */}
|
|
<ScrollView
|
|
ref={mainScrollRef}
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={{
|
|
paddingTop: insets.top + TOP_PADDING,
|
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
|
paddingBottom: insets.bottom + 60,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Top section - Content + Poster */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
minHeight: SCREEN_HEIGHT * 0.45,
|
|
}}
|
|
>
|
|
{/* Left side - Content */}
|
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
|
<TVSeriesHeader item={item} />
|
|
|
|
{/* Action buttons */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
gap: 16,
|
|
marginTop: 32,
|
|
}}
|
|
>
|
|
<TVFocusableButton
|
|
onPress={handlePlayNextEpisode}
|
|
hasTVPreferredFocus={!isSeasonModalVisible}
|
|
disabled={isSeasonModalVisible}
|
|
variant='primary'
|
|
refSetter={setPlayButtonRef}
|
|
>
|
|
<Ionicons
|
|
name='play'
|
|
size={28}
|
|
color='#000000'
|
|
style={{ marginRight: 10 }}
|
|
/>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.body,
|
|
fontWeight: "bold",
|
|
color: "#000000",
|
|
}}
|
|
>
|
|
{playButtonText}
|
|
</Text>
|
|
</TVFocusableButton>
|
|
|
|
{seasons.length > 1 && (
|
|
<TVSeasonButton
|
|
seasonName={selectedSeasonName}
|
|
onPress={handleOpenSeasonModal}
|
|
disabled={isSeasonModalVisible}
|
|
/>
|
|
)}
|
|
|
|
<TVFavoriteButton item={item} disabled={isSeasonModalVisible} />
|
|
</View>
|
|
</View>
|
|
|
|
{/* Right side - Poster */}
|
|
<View
|
|
style={{
|
|
width: SCREEN_WIDTH * POSTER_WIDTH_PERCENT,
|
|
marginLeft: 50,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
aspectRatio: 2 / 3,
|
|
borderRadius: 16,
|
|
overflow: "hidden",
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 10 },
|
|
shadowOpacity: 0.5,
|
|
shadowRadius: 20,
|
|
}}
|
|
>
|
|
<ItemImage
|
|
variant='Primary'
|
|
item={item}
|
|
style={{ width: "100%", height: "100%" }}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Episodes section */}
|
|
<View style={{ marginTop: 40, overflow: "visible" }}>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.heading,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
marginBottom: 24,
|
|
marginLeft: SCALE_PADDING,
|
|
}}
|
|
>
|
|
{selectedSeasonName}
|
|
</Text>
|
|
|
|
{/* Bidirectional focus guides - stacked together above the list */}
|
|
{/* Downward: Play button → first episode */}
|
|
{firstEpisodeRef && (
|
|
<TVFocusGuideView
|
|
destinations={[firstEpisodeRef]}
|
|
style={{ height: 1, width: "100%" }}
|
|
/>
|
|
)}
|
|
{/* Upward: episodes → Play button */}
|
|
{playButtonRef && (
|
|
<TVFocusGuideView
|
|
destinations={[playButtonRef]}
|
|
style={{ height: 1, width: "100%" }}
|
|
/>
|
|
)}
|
|
|
|
<TVEpisodeList
|
|
episodes={episodesForSeason}
|
|
disabled={isSeasonModalVisible}
|
|
onEpisodePress={handleEpisodePress}
|
|
onEpisodeLongPress={showItemActions}
|
|
onFocus={handleEpisodeFocus}
|
|
onBlur={handleEpisodeBlur}
|
|
scrollViewRef={episodeListRef}
|
|
firstEpisodeRefSetter={setFirstEpisodeRef}
|
|
emptyText={t("item_card.no_episodes_for_this_season")}
|
|
horizontalPadding={HORIZONTAL_PADDING}
|
|
/>
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
};
|