From 5384c34b277d192269a3fcde6c5ff19a31930e75 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 15 Aug 2025 21:34:36 +0200 Subject: [PATCH] feat: infinite scrolling in favorites tab (#929) Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com> --- components/home/Favorites.tsx | 53 +++-- .../home/InfiniteScrollingCollectionList.tsx | 191 ++++++++++++++++++ 2 files changed, 224 insertions(+), 20 deletions(-) create mode 100644 components/home/InfiniteScrollingCollectionList.tsx diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx index 14636af5..d434c333 100644 --- a/components/home/Favorites.tsx +++ b/components/home/Favorites.tsx @@ -9,7 +9,7 @@ import { Image, Text, View } from "react-native"; import heart from "@/assets/icons/heart.fill.png"; import { Colors } from "@/constants/Colors"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { ScrollingCollectionList } from "./ScrollingCollectionList"; +import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList"; type FavoriteTypes = | "Series" @@ -33,7 +33,11 @@ export const Favorites = () => { }); const fetchFavoritesByType = useCallback( - async (itemType: BaseItemKind) => { + async ( + itemType: BaseItemKind, + startIndex: number = 0, + limit: number = 20, + ) => { const response = await getItemsApi(api as Api).getItems({ userId: user?.Id, sortBy: ["SeriesSortName", "SortName"], @@ -44,16 +48,19 @@ export const Favorites = () => { collapseBoxSetItems: false, excludeLocationTypes: ["Virtual"], enableTotalRecordCount: false, - limit: 20, + startIndex: startIndex, + limit: limit, includeItemTypes: [itemType], }); const items = response.data.Items || []; - // Update empty state for this specific type - setEmptyState((prev) => ({ - ...prev, - [itemType as FavoriteTypes]: items.length === 0, - })); + // Update empty state for this specific type only for the first page + if (startIndex === 0) { + setEmptyState((prev) => ({ + ...prev, + [itemType as FavoriteTypes]: items.length === 0, + })); + } return items; }, @@ -82,27 +89,33 @@ export const Favorites = () => { }; const fetchFavoriteSeries = useCallback( - () => fetchFavoritesByType("Series"), + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Series", pageParam), [fetchFavoritesByType], ); const fetchFavoriteMovies = useCallback( - () => fetchFavoritesByType("Movie"), + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Movie", pageParam), [fetchFavoritesByType], ); const fetchFavoriteEpisodes = useCallback( - () => fetchFavoritesByType("Episode"), + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Episode", pageParam), [fetchFavoritesByType], ); const fetchFavoriteVideos = useCallback( - () => fetchFavoritesByType("Video"), + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Video", pageParam), [fetchFavoritesByType], ); const fetchFavoriteBoxsets = useCallback( - () => fetchFavoritesByType("BoxSet"), + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("BoxSet", pageParam), [fetchFavoritesByType], ); const fetchFavoritePlaylists = useCallback( - () => fetchFavoritesByType("Playlist"), + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Playlist", pageParam), [fetchFavoritesByType], ); @@ -123,38 +136,38 @@ export const Favorites = () => { )} - - - - - - ; + hideIfEmpty?: boolean; + pageSize?: number; +} + +export const InfiniteScrollingCollectionList: React.FC = ({ + title, + orientation = "vertical", + disabled = false, + queryFn, + queryKey, + hideIfEmpty = false, + pageSize = 20, + ...props +}) => { + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = + 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 < pageSize) { + return undefined; + } + // Otherwise, return the next start index + return allPages.length * pageSize; + }, + initialPageParam: 0, + staleTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }); + + const { t } = useTranslation(); + + // Flatten all pages into a single array + const allItems = data?.pages.flat() || []; + + 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 ( + + + {title} + + {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 && ( + + + + )} + + + )} + + ); +};