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 { Colors } from "@/constants/Colors"; import useRouter from "@/hooks/useAppRouter"; import ContinueWatchingPoster, { TV_LANDSCAPE_WIDTH, } from "../ContinueWatchingPoster.tv"; import SeriesPoster from "../posters/SeriesPoster.tv"; const ITEM_GAP = 16; // 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; } // TV-specific ItemCardText with larger fonts const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { return ( {item.Type === "Episode" ? ( <> {item.Name} {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} {" - "} {item.SeriesName} ) : ( <> {item.Name} {item.ProductionYear} )} ); }; export const InfiniteScrollingCollectionList: React.FC = ({ title, orientation = "vertical", disabled = false, queryFn, queryKey, hideIfEmpty = false, pageSize = 10, enabled = true, onLoaded, isFirstSection = false, onItemFocus, ...props }) => { 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 and scroll back to start when leaving const flatListRef = useRef>(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); // When section loses all focus, scroll back to start useEffect(() => { if (prevFocusedCount.current > 0 && focusedCount === 0) { flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); } prevFocusedCount.current = focusedCount; }, [focusedCount]); const handleItemFocus = useCallback( (item: BaseItemDto) => { setFocusedCount((c) => c + 1); onItemFocus?.(item); }, [onItemFocus], ); const handleItemBlur = useCallback(() => { setFocusedCount((c) => Math.max(0, 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, refetchOnMount: false, 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 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, ], ); 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} style={{ overflow: "visible" }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, paddingHorizontal: SCALE_PADDING, }} ListFooterComponent={ isFetchingNextPage ? ( ) : null } /> )} ); };