import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { type QueryFunction, type QueryKey, useInfiniteQuery, } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, ScrollView, View, type ViewProps, } from "react-native"; import { SectionHeader } from "@/components/common/SectionHeader"; import { Text } from "@/components/common/Text"; import MoviePoster from "@/components/posters/MoviePoster"; import { Colors } from "../../constants/Colors"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { ItemCardText } from "../ItemCardText"; import SeriesPoster from "../posters/SeriesPoster"; interface Props extends ViewProps { title?: string | null; orientation?: "horizontal" | "vertical"; disabled?: boolean; queryKey: QueryKey; queryFn: QueryFunction; hideIfEmpty?: boolean; pageSize?: number; onPressSeeAll?: () => void; enabled?: boolean; onLoaded?: () => void; } export const InfiniteScrollingCollectionList: React.FC = ({ title, orientation = "vertical", disabled = false, queryFn, queryKey, hideIfEmpty = false, pageSize = 10, onPressSeeAll, enabled = true, onLoaded, ...props }) => { const effectivePageSize = Math.max(1, pageSize); const hasCalledOnLoaded = useRef(false); const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, isSuccess, } = useInfiniteQuery({ queryKey: queryKey, queryFn: ({ pageParam = 0, ...context }) => queryFn({ ...context, queryKey, pageParam }), getNextPageParam: (lastPage, allPages) => { // If the last page has fewer items than pageSize, we've reached the end if (lastPage.length < effectivePageSize) { return undefined; } // Otherwise, return the next start index based on how many items we already loaded. // This avoids overlaps if the server/page size differs from our configured page size. return allPages.reduce((acc, page) => acc + page.length, 0); }, initialPageParam: 0, staleTime: 60 * 1000, // 1 minute refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: true, enabled, }); // Notify parent when data has loaded useEffect(() => { if (isSuccess && !hasCalledOnLoaded.current && onLoaded) { hasCalledOnLoaded.current = true; onLoaded(); } }, [isSuccess, onLoaded]); const { t } = useTranslation(); // Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates) const allItems = useMemo(() => { const items = data?.pages.flat() ?? []; const seen = new Set(); const deduped: BaseItemDto[] = []; for (const item of items) { const id = item.Id; if (!id) continue; if (seen.has(id)) continue; seen.add(id); deduped.push(item); } return deduped; }, [data]); const snapOffsets = useMemo(() => { const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px) return allItems.map((_, index) => index * itemWidth); }, [allItems, orientation]); if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null; if (disabled || !title) return null; const handleScroll = (event: any) => { const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; const paddingToBottom = 20; // Check if we're near the end of the scroll if ( layoutMeasurement.width + contentOffset.x >= contentSize.width - paddingToBottom ) { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); } } }; return ( {isLoading === false && allItems.length === 0 && ( {t("home.no_items")} )} {isLoading ? ( {[1, 2, 3].map((i) => ( Nisi mollit voluptate amet. Lorem ipsum ))} ) : ( {allItems.map((item) => ( {item.Type === "Episode" && orientation === "horizontal" && ( )} {item.Type === "Episode" && orientation === "vertical" && ( )} {item.Type === "Movie" && orientation === "horizontal" && ( )} {item.Type === "Movie" && orientation === "vertical" && ( )} {item.Type === "Series" && orientation === "vertical" && ( )} {item.Type === "Series" && orientation === "horizontal" && ( )} {item.Type === "Program" && ( )} {item.Type === "BoxSet" && orientation === "vertical" && ( )} {item.Type === "BoxSet" && orientation === "horizontal" && ( )} {item.Type === "Playlist" && orientation === "vertical" && ( )} {item.Type === "Playlist" && orientation === "horizontal" && ( )} {item.Type === "Video" && orientation === "vertical" && ( )} {item.Type === "Video" && orientation === "horizontal" && ( )} ))} {/* Loading indicator for next page */} {isFetchingNextPage && ( )} )} ); };