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 (
{
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
{children}
);
};
// 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 (
{
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
disabled={disabled}
focusable={!disabled}
>
{seasonName}
);
};
export const TVSeriesPage: React.FC = ({
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(null);
const [firstEpisodeRef, setFirstEpisodeRef] = useState(null);
// ScrollView ref for page scrolling
const mainScrollRef = useRef(null);
// ScrollView ref for scrolling back
const episodeListRef = useRef(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 (
{/* Full-screen backdrop */}
{/* Gradient overlays for readability */}
{/* Main content */}
{/* Top section - Content + Poster */}
{/* Left side - Content */}
{/* Action buttons */}
{playButtonText}
{seasons.length > 1 && (
)}
{/* Right side - Poster */}
{/* Episodes section */}
{selectedSeasonName}
{/* Bidirectional focus guides - stacked together above the list */}
{/* Downward: Play button → first episode */}
{firstEpisodeRef && (
)}
{/* Upward: episodes → Play button */}
{playButtonRef && (
)}
);
};