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, useEffect, useMemo, useRef, useState } 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 MoviePoster, { TV_POSTER_WIDTH, } from "@/components/posters/MoviePoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import ContinueWatchingPoster, { TV_LANDSCAPE_WIDTH, } from "../ContinueWatchingPoster.tv"; import SeriesPoster from "../posters/SeriesPoster.tv"; const ITEM_GAP = 24; // 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; onLoaded?: () => void; isFirstSection?: boolean; onItemFocus?: (item: BaseItemDto) => void; parentId?: string; } type Typography = ReturnType; // TV-specific ItemCardText with larger fonts const TVItemCardText: React.FC<{ item: BaseItemDto; typography: Typography; }> = ({ item, typography }) => { return ( {item.Type === "Episode" ? ( <> {item.Name} {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} {" - "} {item.SeriesName} ) : ( <> {item.Name} {item.ProductionYear} )} ); }; // 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; }> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => { const { t } = useTranslation(); const width = orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; 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, onLoaded, isFirstSection = false, onItemFocus, parentId, ...props }) => { const typography = useScaledTVTypography(); const effectivePageSize = Math.max(1, pageSize); const hasCalledOnLoaded = useRef(false); const router = useRouter(); const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; // Track focus within section for item focus/blur callbacks const flatListRef = useRef>(null); const [_focusedCount, setFocusedCount] = useState(0); const handleItemFocus = useCallback( (item: BaseItemDto) => { setFocusedCount((c) => c + 1); onItemFocus?.(item); }, [onItemFocus], ); const handleItemBlur = useCallback(() => { setFocusedCount((c) => Math.max(0, c - 1)); }, []); // Focus handler for See All card (doesn't need item parameter) const handleSeeAllFocus = useCallback(() => { setFocusedCount((c) => c + 1); }, []); const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, isSuccess, } = 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, }); useEffect(() => { if (isSuccess && !hasCalledOnLoaded.current && onLoaded) { hasCalledOnLoaded.current = true; onLoaded(); } }, [isSuccess, onLoaded]); 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" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; 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 getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ length: itemWidth + ITEM_GAP, offset: (itemWidth + ITEM_GAP) * index, index, }), [itemWidth], ); const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => { const isFirstItem = isFirstSection && index === 0; const isHorizontal = orientation === "horizontal"; const renderPoster = () => { if (item.Type === "Episode" && isHorizontal) { return ; } if (item.Type === "Episode" && !isHorizontal) { return ; } if (item.Type === "Movie" && isHorizontal) { return ; } if (item.Type === "Movie" && !isHorizontal) { return ; } if (item.Type === "Series" && !isHorizontal) { return ; } if (item.Type === "Series" && isHorizontal) { return ; } if (item.Type === "Program") { return ; } if (item.Type === "BoxSet" && !isHorizontal) { return ; } if (item.Type === "BoxSet" && isHorizontal) { return ; } if (item.Type === "Playlist" && !isHorizontal) { return ; } if (item.Type === "Playlist" && isHorizontal) { return ; } if (item.Type === "Video" && !isHorizontal) { return ; } if (item.Type === "Video" && isHorizontal) { return ; } // Default fallback return isHorizontal ? ( ) : ( ); }; return ( handleItemPress(item)} hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} onBlur={handleItemBlur} > {renderPoster()} ); }, [ orientation, isFirstSection, itemWidth, handleItemPress, handleItemFocus, handleItemBlur, typography, ], ); 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} getItemLayout={getItemLayout} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} style={{ overflow: "visible" }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, paddingHorizontal: SCALE_PADDING, }} ListFooterComponent={ {isFetchingNextPage && ( )} {parentId && allItems.length > 0 && ( )} } /> )} ); };