From 5281cba2842a79807ffce8869b698436edb59819 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 18 Aug 2024 10:52:20 +0200 Subject: [PATCH] feat: inf horizontal scroll for media lists --- .../common/InfiniteHorrizontalScroll.tsx | 164 ++++++++++++++++++ components/medialists/MediaListSection.tsx | 82 +++++---- 2 files changed, 213 insertions(+), 33 deletions(-) create mode 100644 components/common/InfiniteHorrizontalScroll.tsx diff --git a/components/common/InfiniteHorrizontalScroll.tsx b/components/common/InfiniteHorrizontalScroll.tsx new file mode 100644 index 00000000..9402feff --- /dev/null +++ b/components/common/InfiniteHorrizontalScroll.tsx @@ -0,0 +1,164 @@ +import React, { useEffect } from "react"; +import { + NativeScrollEvent, + ScrollView, + ScrollViewProps, + View, + ViewStyle, +} from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Loader } from "../Loader"; +import { Text } from "./Text"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { + BaseItemDto, + BaseItemDtoQueryResult, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useNavigation } from "expo-router"; +import { useAtom } from "jotai"; + +interface HorizontalScrollProps extends ScrollViewProps { + queryFn: ({ + pageParam, + }: { + pageParam: number; + }) => Promise; + queryKey: string[]; + initialData?: BaseItemDto[]; + renderItem: (item: BaseItemDto, index: number) => React.ReactNode; + containerStyle?: ViewStyle; + contentContainerStyle?: ViewStyle; + loadingContainerStyle?: ViewStyle; + height?: number; + loading?: boolean; +} + +const isCloseToBottom = ({ + layoutMeasurement, + contentOffset, + contentSize, +}: NativeScrollEvent) => { + const paddingToBottom = 50; + return ( + layoutMeasurement.height + contentOffset.y >= + contentSize.height - paddingToBottom + ); +}; + +export function InfiniteHorizontalScroll({ + queryFn, + queryKey, + initialData = [], + renderItem, + containerStyle, + contentContainerStyle, + loadingContainerStyle, + loading = false, + height = 164, + ...props +}: HorizontalScrollProps): React.ReactElement { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const navigation = useNavigation(); + + const animatedOpacity = useSharedValue(0); + const animatedStyle1 = useAnimatedStyle(() => { + return { + opacity: withTiming(animatedOpacity.value, { duration: 250 }), + }; + }); + + const { data, isFetching, fetchNextPage } = useInfiniteQuery({ + queryKey, + queryFn, + getNextPageParam: (lastPage, pages) => { + if ( + !lastPage?.Items || + !lastPage?.TotalRecordCount || + lastPage?.TotalRecordCount === 0 + ) + return undefined; + + const totalItems = lastPage.TotalRecordCount; + const accumulatedItems = pages.reduce( + (acc, curr) => acc + (curr?.Items?.length || 0), + 0 + ); + + if (accumulatedItems < totalItems) { + return lastPage?.Items?.length * pages.length; + } else { + return undefined; + } + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id, + }); + + useEffect(() => { + if (data) { + animatedOpacity.value = 1; + } + }, [data]); + + if (data === undefined || data === null || loading) { + return ( + + + + ); + } + + return ( + { + if (isCloseToBottom(nativeEvent)) { + fetchNextPage(); + } + }} + scrollEventThrottle={400} + style={containerStyle} + contentContainerStyle={contentContainerStyle} + showsHorizontalScrollIndicator={false} + {...props} + > + + {data?.pages + .flatMap((page) => page?.Items) + .map( + (item, index) => + item && ( + + {renderItem(item, index)} + + ) + )} + {data?.pages.flatMap((page) => page?.Items).length === 0 && ( + + No data available + + )} + + + ); +} diff --git a/components/medialists/MediaListSection.tsx b/components/medialists/MediaListSection.tsx index 6b449eec..99732831 100644 --- a/components/medialists/MediaListSection.tsx +++ b/components/medialists/MediaListSection.tsx @@ -1,56 +1,72 @@ -import { View, ViewProps } from "react-native"; -import { Text } from "@/components/common/Text"; -import settings from "@/app/(auth)/settings"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ScrollingCollectionList } from "../home/ScrollingCollectionList"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { useRouter } from "expo-router"; +import { + BaseItemDto, + BaseItemDtoQueryResult, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; -import { useState } from "react"; +import { View, ViewProps } from "react-native"; +import { ScrollingCollectionList } from "../home/ScrollingCollectionList"; +import { Text } from "../common/Text"; +import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll"; +import { TouchableItemRouter } from "../common/TouchableItemRouter"; +import MoviePoster from "../posters/MoviePoster"; +import { useCallback } from "react"; interface Props extends ViewProps { collection: BaseItemDto; } export const MediaListSection: React.FC = ({ collection, ...props }) => { - const router = useRouter(); - const queryClient = useQueryClient(); - const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [loading, setLoading] = useState(false); - const [settings, _] = useSettings(); - - const { data: popularItems, isLoading: isLoadingPopular } = useQuery< - BaseItemDto[] - >({ - queryKey: [collection.Id, user?.Id], - queryFn: async () => { - if (!api || !user?.Id || !collection.Id) return []; + const fetchItems = useCallback( + async ({ + pageParam, + }: { + pageParam: number; + }): Promise => { + if (!api || !user?.Id) return null; const response = await getItemsApi(api).getItems({ userId: user.Id, parentId: collection.Id, + startIndex: pageParam, limit: 10, }); - return response.data.Items || []; + return response.data; }, - enabled: !!api && !!user?.Id && !!collection.Id, - staleTime: 0, - }); + [api, user?.Id, collection.Id] + ); + + if (!collection) return null; return ( - + + + {collection.Name} + + ( + + + + + + )} + queryFn={fetchItems} + queryKey={["media-list", collection.Id!]} + /> + ); };