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, useQueryClient } from "@tanstack/react-query"; import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useRef } from "react"; import { TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { HorizontalScroll, type HorizontalScrollRef, } from "@/components/common/HorizontalScroll"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { SeasonDropdown, type SeasonIndexState, } from "@/components/series/SeasonDropdown"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getDownloadedEpisodesForSeason, getDownloadedSeasonNumbers, } from "@/utils/downloads/offline-series"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { runtimeTicksToSeconds } from "@/utils/time"; import { HEADER_LAYOUT, ICON_SIZES } from "./constants"; type Props = { item: BaseItemDto; close: () => void; goToItem: (item: BaseItemDto) => void; }; export const seasonIndexAtom = atom({}); export const EpisodeList: React.FC = ({ item, close, goToItem }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const scrollViewRef = useRef(null); // Reference to the HorizontalScroll const scrollToIndex = (index: number) => { scrollViewRef.current?.scrollToIndex(index, 100); }; const isOffline = useOfflineMode(); const { settings } = useSettings(); const insets = useSafeAreaInsets(); // Set the initial season index useEffect(() => { if (item.SeriesId) { setSeasonIndexState((prev) => ({ ...prev, [item.ParentId ?? ""]: item.ParentIndexNumber ?? 0, })); } }, []); const { getDownloadedItems } = useDownload(); const seasonIndex = seasonIndexState[item.ParentId ?? ""]; const { data: seasons } = useQuery({ queryKey: ["seasons", item.SeriesId], queryFn: async () => { if (isOffline) { if (!item.SeriesId) return []; const seasonNumbers = getDownloadedSeasonNumbers( getDownloadedItems(), item.SeriesId, ); // Create fake season objects return seasonNumbers.map((seasonNumber) => ({ Id: seasonNumber?.toString(), IndexNumber: seasonNumber, Name: `Season ${seasonNumber}`, SeriesId: item.SeriesId, })); } if (!api || !user?.Id || !item.SeriesId) return []; const response = await getTvShowsApi(api).getSeasons({ seriesId: item.SeriesId, userId: user.Id, fields: [ "ItemCounts", "PrimaryImageAspectRatio", "CanDelete", "MediaSourceCount", ], }); return response.data.Items; }, enabled: isOffline ? !!item.SeriesId : !!api && !!user?.Id && !!item.SeasonId, }); const selectedSeasonId: string | null = useMemo( () => seasons ?.find((season: any) => season.IndexNumber === seasonIndex) ?.Id?.toString() || null, [seasons, seasonIndex], ); const { data: episodes, isLoading: episodesLoading } = useQuery({ queryKey: ["episodes", item.SeriesId, selectedSeasonId], queryFn: async () => { if (isOffline) { if (!item.SeriesId || typeof seasonIndex !== "number") return []; return getDownloadedEpisodesForSeason( getDownloadedItems(), item.SeriesId, seasonIndex, ); } if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; const res = await getTvShowsApi(api).getEpisodes({ seriesId: item.SeriesId || "", userId: user.Id, seasonId: selectedSeasonId || undefined, enableUserData: true, fields: ["MediaSources", "MediaStreams", "Overview"], }); return res.data.Items; }, enabled: !!api && !!user?.Id && !!selectedSeasonId, }); useEffect(() => { if (item?.Type === "Episode" && item.Id) { const index = episodes?.findIndex((ep: BaseItemDto) => ep.Id === item.Id); if (index !== undefined && index !== -1) { setTimeout(() => { scrollToIndex(index); }, 400); } } }, [episodes, item]); const queryClient = useQueryClient(); useEffect(() => { // Don't prefetch when offline - data is already local if (isOffline) return; for (const e of episodes || []) { queryClient.prefetchQuery({ queryKey: ["item", e.Id], queryFn: async () => { if (!e.Id) return; const res = await getUserItemData({ api, userId: user?.Id, itemId: e.Id, }); return res; }, staleTime: 60 * 5 * 1000, }); } }, [episodes, isOffline]); // Scroll to the current item when episodes are fetched useEffect(() => { if (episodes && scrollViewRef.current) { const currentItemIndex = episodes.findIndex((e) => e.Id === item.Id); if (currentItemIndex !== -1) { scrollViewRef.current.scrollToIndex(currentItemIndex, 16); // Adjust the scroll position based on item width } } }, [episodes, item.Id]); return ( {seasons && seasons.length > 0 && !episodesLoading && episodes && ( { setSeasonIndexState((prev) => ({ ...prev, [item.ParentId ?? ""]: season.IndexNumber, })); }} /> )} { close(); }} className='aspect-square flex flex-col rounded-xl items-center justify-center p-2 ml-auto' > {!episodes || episodesLoading ? ( ) : ( ( { goToItem(otherItem); }} > {otherItem.Name} {`S${otherItem.ParentIndexNumber?.toString()}:E${otherItem.IndexNumber?.toString()}`} {runtimeTicksToSeconds(otherItem.RunTimeTicks)} {otherItem.Overview} )} keyExtractor={(e: BaseItemDto) => e.Id ?? ""} showsHorizontalScrollIndicator={false} /> )} ); };