import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { type QueryFunction, type QueryKey, useInfiniteQuery, } from "@tanstack/react-query"; import { useSegments } from "expo-router"; import { useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, FlatList, View, type ViewProps, } from "react-native"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; // Extra padding to accommodate scale animation (1.05x) and glow shadow const SCALE_PADDING = 20; interface Props extends ViewProps { title?: string | null; orientation?: "horizontal" | "vertical"; disabled?: boolean; queryKey: QueryKey; queryFn: QueryFunction; hideIfEmpty?: boolean; pageSize?: number; onPressSeeAll?: () => void; enabled?: boolean; isFirstSection?: boolean; onItemFocus?: (item: BaseItemDto) => void; parentId?: string; } type Typography = ReturnType; type PosterSizes = ReturnType; // TV-specific "See All" card for end of lists const TVSeeAllCard: React.FC<{ onPress: () => void; orientation: "horizontal" | "vertical"; disabled?: boolean; onFocus?: () => void; onBlur?: () => void; typography: Typography; posterSizes: PosterSizes; }> = ({ onPress, orientation, disabled, onFocus, onBlur, typography, posterSizes, }) => { const { t } = useTranslation(); const width = orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15; return ( {t("common.seeAll", { defaultValue: "See all" })} ); }; export const InfiniteScrollingCollectionList: React.FC = ({ title, orientation = "vertical", disabled = false, queryFn, queryKey, hideIfEmpty = false, pageSize = 10, enabled = true, isFirstSection = false, onItemFocus, parentId, ...props }) => { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); const sizes = useScaledTVSizes(); const ITEM_GAP = sizes.gaps.item; const effectivePageSize = Math.max(1, pageSize); const router = useRouter(); const { showItemActions } = useTVItemActionModal(); const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; const flatListRef = useRef>(null); // Pass through focus callbacks without tracking internal state const handleItemFocus = useCallback( (item: BaseItemDto) => { onItemFocus?.(item); }, [onItemFocus], ); const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({ queryKey: queryKey, queryFn: ({ pageParam = 0, ...context }) => queryFn({ ...context, queryKey, pageParam }), getNextPageParam: (lastPage, allPages) => { if (lastPage.length < effectivePageSize) { return undefined; } return allPages.reduce((acc, page) => acc + page.length, 0); }, initialPageParam: 0, staleTime: 60 * 1000, refetchInterval: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: true, enabled, }); const { t } = useTranslation(); 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 itemWidth = orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; const handleItemPress = useCallback( (item: BaseItemDto) => { const navigation = getItemNavigation(item, from); router.push(navigation as any); }, [from, router], ); const handleEndReached = useCallback(() => { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, [hasNextPage, isFetchingNextPage, fetchNextPage]); const handleSeeAllPress = useCallback(() => { if (!parentId) return; router.push({ pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", params: { libraryId: parentId, sortBy: SortByOption.DateCreated, sortOrder: SortOrderOption.Descending, }, } as any); }, [router, parentId]); const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => { const isFirstItem = isFirstSection && index === 0; return ( handleItemPress(item)} onLongPress={() => showItemActions(item)} hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} width={itemWidth} /> ); }, [ orientation, isFirstSection, itemWidth, handleItemPress, showItemActions, handleItemFocus, ITEM_GAP, ], ); if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null; if (disabled || !title) return null; return ( {/* Section Header */} {title} {isLoading === false && allItems.length === 0 && ( {t("home.no_items")} )} {isLoading ? ( {[1, 2, 3, 4, 5].map((i) => ( Placeholder text here ))} ) : ( item.Id!} renderItem={renderItem} showsHorizontalScrollIndicator={false} onEndReached={handleEndReached} onEndReachedThreshold={0.5} initialNumToRender={5} maxToRenderPerBatch={3} windowSize={5} removeClippedSubviews={false} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} style={{ overflow: "visible" }} contentInset={{ left: sizes.padding.horizontal, right: sizes.padding.horizontal, }} contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, }} ListFooterComponent={ {isFetchingNextPage && ( )} {parentId && allItems.length > 0 && ( )} } /> )} ); };