diff --git a/app.json b/app.json index f12b80fb..b38723a0 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.47.0", + "version": "0.47.1", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -38,7 +38,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 83, + "versionCode": 84, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", diff --git a/app/_layout.tsx b/app/_layout.tsx index cb6eb341..b373739c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -15,6 +15,7 @@ import { getOrSetDeviceId, JellyfinProvider, } from "@/providers/JellyfinProvider"; +import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -338,63 +339,65 @@ function Layout() { return ( - - - - - - - - - - - - - - + + + + + + + + + ); diff --git a/bun.lock b/bun.lock index 76012d57..dd515c17 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ "@react-navigation/material-top-tabs": "^7.2.14", "@react-navigation/native": "^7.0.14", "@shopify/flash-list": "2.0.2", - "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query": "^5.90.7", "axios": "^1.7.9", "expo": "^54.0.23", "expo-application": "~7.0.5", diff --git a/components/common/InfiniteHorizontalScroll.tsx b/components/common/InfiniteHorizontalScroll.tsx index 1f24d575..182a2817 100644 --- a/components/common/InfiniteHorizontalScroll.tsx +++ b/components/common/InfiniteHorizontalScroll.tsx @@ -59,6 +59,7 @@ export function InfiniteHorizontalScroll({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey, queryFn, + staleTime: 60 * 1000, // 1 minute getNextPageParam: (lastPage, pages) => { if ( !lastPage?.Items || diff --git a/components/home/Home.tsx b/components/home/Home.tsx index 44298311..9ce6c12b 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -1,5 +1,4 @@ import { Feather, Ionicons } from "@expo/vector-icons"; -import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto, BaseItemDtoQueryResult, @@ -28,7 +27,7 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; -import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList"; import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; import { Colors } from "@/constants/Colors"; @@ -39,12 +38,13 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; -type ScrollingCollectionListSection = { - type: "ScrollingCollectionList"; +type InfiniteScrollingCollectionListSection = { + type: "InfiniteScrollingCollectionList"; title?: string; queryKey: (string | undefined | null)[]; - queryFn: QueryFunction; + queryFn: QueryFunction; orientation?: "horizontal" | "vertical"; + pageSize?: number; }; type MediaListSectionType = { @@ -53,7 +53,7 @@ type MediaListSectionType = { queryFn: QueryFunction; }; -type Section = ScrollingCollectionListSection | MediaListSectionType; +type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; export const Home = () => { const router = useRouter(); @@ -74,6 +74,11 @@ export const Home = () => { retryCheck, } = useNetworkStatus(); const invalidateCache = useInvalidatePlaybackProgressCache(); + const [scrollY, setScrollY] = useState(0); + + useEffect(() => { + console.log("scrollY", scrollY); + }, [scrollY]); useEffect(() => { if (isConnected && !prevIsConnected.current) { @@ -182,26 +187,31 @@ export const Home = () => { queryKey: string[], includeItemTypes: BaseItemKind[], parentId: string | undefined, - ): ScrollingCollectionListSection => ({ + pageSize: number = 10, + ): InfiniteScrollingCollectionListSection => ({ title, queryKey, - queryFn: async () => { + queryFn: async ({ pageParam = 0 }) => { if (!api) return []; - return ( + // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side + const allData = ( await getUserLibraryApi(api).getLatestMedia({ userId: user?.Id, - limit: 20, + limit: 100, // Fetch a larger set for pagination fields: ["PrimaryImageAspectRatio", "Path", "Genres"], imageTypeLimit: 1, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], includeItemTypes, parentId, }) - ).data || [] - ); + ).data || []; + + // Simulate pagination by slicing + return allData.slice(pageParam, pageParam + pageSize); }, - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", + pageSize, }), [api, user?.Id], ); @@ -226,6 +236,7 @@ export const Home = () => { queryKey, includeItemTypes, c.Id, + 10, ); }); @@ -233,69 +244,56 @@ export const Home = () => { { title: t("home.continue_watching"), queryKey: ["home", "resumeItems"], - queryFn: async () => + queryFn: async ({ pageParam = 0 }) => ( await getItemsApi(api).getResumeItems({ userId: user.Id, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], includeItemTypes: ["Movie", "Series", "Episode"], fields: ["Genres"], + startIndex: pageParam, + limit: 10, }) ).data.Items || [], - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", orientation: "horizontal", + pageSize: 10, }, { title: t("home.next_up"), queryKey: ["home", "nextUp-all"], - queryFn: async () => + queryFn: async ({ pageParam = 0 }) => ( await getTvShowsApi(api).getNextUp({ userId: user?.Id, fields: ["MediaSourceCount", "Genres"], - limit: 20, + startIndex: pageParam, + limit: 10, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableResumable: false, }) ).data.Items || [], - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", orientation: "horizontal", + pageSize: 10, }, ...latestMediaViews, { title: t("home.suggested_movies"), queryKey: ["home", "suggestedMovies", user?.Id], - queryFn: async () => + queryFn: async ({ pageParam = 0 }) => ( await getSuggestionsApi(api).getSuggestions({ userId: user?.Id, + startIndex: pageParam, limit: 10, mediaType: ["Video"], type: ["Movie"], }) ).data.Items || [], - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", orientation: "vertical", - }, - { - title: t("home.suggested_episodes"), - queryKey: ["home", "suggestedEpisodes", user?.Id], - queryFn: async () => { - try { - const suggestions = await getSuggestions(api, user.Id); - const nextUpPromises = suggestions.map((series) => - getNextUp(api, user.Id, series.Id), - ); - const nextUpResults = await Promise.all(nextUpPromises); - - return nextUpResults.filter((item) => item !== null) || []; - } catch (error) { - console.error("Error fetching data:", error); - return []; - } - }, - type: "ScrollingCollectionList", - orientation: "horizontal", + pageSize: 10, }, ]; return ss; @@ -306,14 +304,16 @@ export const Home = () => { const ss: Section[] = []; settings.home.sections.forEach((section, index) => { const id = section.title || `section-${index}`; + const pageSize = 10; ss.push({ title: t(`${id}`), queryKey: ["home", "custom", String(index), section.title ?? null], - queryFn: async () => { + queryFn: async ({ pageParam = 0 }) => { if (section.items) { const response = await getItemsApi(api).getItems({ userId: user?.Id, - limit: section.items?.limit || 25, + startIndex: pageParam, + limit: section.items?.limit || pageSize, recursive: true, includeItemTypes: section.items?.includeItemTypes, sortBy: section.items?.sortBy, @@ -327,7 +327,8 @@ export const Home = () => { const response = await getTvShowsApi(api).getNextUp({ userId: user?.Id, fields: ["MediaSourceCount", "Genres"], - limit: section.nextUp?.limit || 25, + startIndex: pageParam, + limit: section.nextUp?.limit || pageSize, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableResumable: section.nextUp?.enableResumable, enableRewatching: section.nextUp?.enableRewatching, @@ -335,20 +336,31 @@ export const Home = () => { return response.data.Items || []; } if (section.latest) { - const response = await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - includeItemTypes: section.latest?.includeItemTypes, - limit: section.latest?.limit || 25, - isPlayed: section.latest?.isPlayed, - groupItems: section.latest?.groupItems, - }); - return response.data || []; + // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side + const allData = + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + includeItemTypes: section.latest?.includeItemTypes, + limit: section.latest?.limit || 100, // Fetch larger set + isPlayed: section.latest?.isPlayed, + groupItems: section.latest?.groupItems, + }) + ).data || []; + + // Simulate pagination by slicing + return allData.slice(pageParam, pageParam + pageSize); } if (section.custom) { const response = await api.get( section.custom.endpoint, { - params: { ...(section.custom.query || {}), userId: user?.Id }, + params: { + ...(section.custom.query || {}), + userId: user?.Id, + startIndex: pageParam, + limit: pageSize, + }, headers: section.custom.headers || {}, }, ); @@ -356,12 +368,13 @@ export const Home = () => { } return []; }, - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", orientation: section?.orientation || "vertical", + pageSize, }); }); return ss; - }, [api, user?.Id, settings?.home?.sections]); + }, [api, user?.Id, settings?.home?.sections, t]); const sections = settings?.home?.sections ? customSections : defaultSections; @@ -442,6 +455,10 @@ export const Home = () => { ref={scrollRef} nestedScrollEnabled contentInsetAdjustmentBehavior='automatic' + onScroll={(event) => { + setScrollY(event.nativeEvent.contentOffset.y - 500); + }} + scrollEventThrottle={16} refreshControl={ { style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} > {sections.map((section, index) => { - if (section.type === "ScrollingCollectionList") { + if (section.type === "InfiniteScrollingCollectionList") { return ( - ); } @@ -479,6 +497,8 @@ export const Home = () => { key={index} queryKey={section.queryKey} queryFn={section.queryFn} + scrollY={scrollY} + enableLazyLoading={true} /> ); } @@ -488,28 +508,3 @@ export const Home = () => { ); }; - -async function getSuggestions(api: Api, userId: string | undefined) { - if (!userId) return []; - const response = await getSuggestionsApi(api).getSuggestions({ - userId, - limit: 10, - mediaType: ["Unknown"], - type: ["Series"], - }); - return response.data.Items ?? []; -} - -async function getNextUp( - api: Api, - userId: string | undefined, - seriesId: string | undefined, -) { - if (!userId || !seriesId) return null; - const response = await getTvShowsApi(api).getNextUp({ - userId, - seriesId, - limit: 1, - }); - return response.data.Items?.[0] ?? null; -} diff --git a/components/home/HomeWithCarousel.tsx b/components/home/HomeWithCarousel.tsx index 18b107fe..43e7fefb 100644 --- a/components/home/HomeWithCarousel.tsx +++ b/components/home/HomeWithCarousel.tsx @@ -1,5 +1,4 @@ import { Feather, Ionicons } from "@expo/vector-icons"; -import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto, BaseItemDtoQueryResult, @@ -30,7 +29,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; -import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList"; import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; import { Colors } from "@/constants/Colors"; @@ -42,12 +41,13 @@ import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel"; -type ScrollingCollectionListSection = { - type: "ScrollingCollectionList"; +type InfiniteScrollingCollectionListSection = { + type: "InfiniteScrollingCollectionList"; title?: string; queryKey: (string | undefined | null)[]; - queryFn: QueryFunction; + queryFn: QueryFunction; orientation?: "horizontal" | "vertical"; + pageSize?: number; }; type MediaListSectionType = { @@ -56,7 +56,7 @@ type MediaListSectionType = { queryFn: QueryFunction; }; -type Section = ScrollingCollectionListSection | MediaListSectionType; +type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; export const HomeWithCarousel = () => { const router = useRouter(); @@ -79,6 +79,7 @@ export const HomeWithCarousel = () => { retryCheck, } = useNetworkStatus(); const invalidateCache = useInvalidatePlaybackProgressCache(); + const [scrollY, setScrollY] = useState(0); useEffect(() => { if (isConnected && !prevIsConnected.current) { @@ -187,26 +188,31 @@ export const HomeWithCarousel = () => { queryKey: string[], includeItemTypes: BaseItemKind[], parentId: string | undefined, - ): ScrollingCollectionListSection => ({ + pageSize: number = 10, + ): InfiniteScrollingCollectionListSection => ({ title, queryKey, - queryFn: async () => { + queryFn: async ({ pageParam = 0 }) => { if (!api) return []; - return ( + // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side + const allData = ( await getUserLibraryApi(api).getLatestMedia({ userId: user?.Id, - limit: 20, + limit: 100, // Fetch a larger set for pagination fields: ["PrimaryImageAspectRatio", "Path", "Genres"], imageTypeLimit: 1, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], includeItemTypes, parentId, }) - ).data || [] - ); + ).data || []; + + // Simulate pagination by slicing + return allData.slice(pageParam, pageParam + pageSize); }, - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", + pageSize, }), [api, user?.Id], ); @@ -231,6 +237,7 @@ export const HomeWithCarousel = () => { queryKey, includeItemTypes, c.Id, + 10, ); }); @@ -238,69 +245,56 @@ export const HomeWithCarousel = () => { { title: t("home.continue_watching"), queryKey: ["home", "resumeItems"], - queryFn: async () => + queryFn: async ({ pageParam = 0 }) => ( await getItemsApi(api).getResumeItems({ userId: user.Id, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], includeItemTypes: ["Movie", "Series", "Episode"], fields: ["Genres"], + startIndex: pageParam, + limit: 10, }) ).data.Items || [], - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", orientation: "horizontal", + pageSize: 10, }, { title: t("home.next_up"), queryKey: ["home", "nextUp-all"], - queryFn: async () => + queryFn: async ({ pageParam = 0 }) => ( await getTvShowsApi(api).getNextUp({ userId: user?.Id, fields: ["MediaSourceCount", "Genres"], - limit: 20, + startIndex: pageParam, + limit: 10, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableResumable: false, }) ).data.Items || [], - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", orientation: "horizontal", + pageSize: 10, }, ...latestMediaViews, { title: t("home.suggested_movies"), queryKey: ["home", "suggestedMovies", user?.Id], - queryFn: async () => + queryFn: async ({ pageParam = 0 }) => ( await getSuggestionsApi(api).getSuggestions({ userId: user?.Id, + startIndex: pageParam, limit: 10, mediaType: ["Video"], type: ["Movie"], }) ).data.Items || [], - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", orientation: "vertical", - }, - { - title: t("home.suggested_episodes"), - queryKey: ["home", "suggestedEpisodes", user?.Id], - queryFn: async () => { - try { - const suggestions = await getSuggestions(api, user.Id); - const nextUpPromises = suggestions.map((series) => - getNextUp(api, user.Id, series.Id), - ); - const nextUpResults = await Promise.all(nextUpPromises); - - return nextUpResults.filter((item) => item !== null) || []; - } catch (error) { - console.error("Error fetching data:", error); - return []; - } - }, - type: "ScrollingCollectionList", - orientation: "horizontal", + pageSize: 10, }, ]; return ss; @@ -311,14 +305,16 @@ export const HomeWithCarousel = () => { const ss: Section[] = []; settings.home.sections.forEach((section, index) => { const id = section.title || `section-${index}`; + const pageSize = 10; ss.push({ title: t(`${id}`), queryKey: ["home", "custom", String(index), section.title ?? null], - queryFn: async () => { + queryFn: async ({ pageParam = 0 }) => { if (section.items) { const response = await getItemsApi(api).getItems({ userId: user?.Id, - limit: section.items?.limit || 25, + startIndex: pageParam, + limit: section.items?.limit || pageSize, recursive: true, includeItemTypes: section.items?.includeItemTypes, sortBy: section.items?.sortBy, @@ -332,7 +328,8 @@ export const HomeWithCarousel = () => { const response = await getTvShowsApi(api).getNextUp({ userId: user?.Id, fields: ["MediaSourceCount", "Genres"], - limit: section.nextUp?.limit || 25, + startIndex: pageParam, + limit: section.nextUp?.limit || pageSize, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableResumable: section.nextUp?.enableResumable, enableRewatching: section.nextUp?.enableRewatching, @@ -340,20 +337,31 @@ export const HomeWithCarousel = () => { return response.data.Items || []; } if (section.latest) { - const response = await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - includeItemTypes: section.latest?.includeItemTypes, - limit: section.latest?.limit || 25, - isPlayed: section.latest?.isPlayed, - groupItems: section.latest?.groupItems, - }); - return response.data || []; + // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side + const allData = + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + includeItemTypes: section.latest?.includeItemTypes, + limit: section.latest?.limit || 100, // Fetch larger set + isPlayed: section.latest?.isPlayed, + groupItems: section.latest?.groupItems, + }) + ).data || []; + + // Simulate pagination by slicing + return allData.slice(pageParam, pageParam + pageSize); } if (section.custom) { const response = await api.get( section.custom.endpoint, { - params: { ...(section.custom.query || {}), userId: user?.Id }, + params: { + ...(section.custom.query || {}), + userId: user?.Id, + startIndex: pageParam, + limit: pageSize, + }, headers: section.custom.headers || {}, }, ); @@ -361,12 +369,13 @@ export const HomeWithCarousel = () => { } return []; }, - type: "ScrollingCollectionList", + type: "InfiniteScrollingCollectionList", orientation: section?.orientation || "vertical", + pageSize, }); }); return ss; - }, [api, user?.Id, settings?.home?.sections]); + }, [api, user?.Id, settings?.home?.sections, t]); const sections = settings?.home?.sections ? customSections : defaultSections; @@ -453,6 +462,9 @@ export const HomeWithCarousel = () => { overScrollMode='never' style={{ marginTop: -headerOverlayOffset }} contentContainerStyle={{ paddingTop: headerOverlayOffset }} + onScroll={(event) => { + setScrollY(event.nativeEvent.contentOffset.y); + }} > { > {sections.map((section, index) => { - if (section.type === "ScrollingCollectionList") { + if (section.type === "InfiniteScrollingCollectionList") { return ( - ); } @@ -483,6 +496,8 @@ export const HomeWithCarousel = () => { key={index} queryKey={section.queryKey} queryFn={section.queryFn} + scrollY={scrollY} + enableLazyLoading={true} /> ); } @@ -494,28 +509,3 @@ export const HomeWithCarousel = () => { ); }; - -async function getSuggestions(api: Api, userId: string | undefined) { - if (!userId) return []; - const response = await getSuggestionsApi(api).getSuggestions({ - userId, - limit: 10, - mediaType: ["Unknown"], - type: ["Series"], - }); - return response.data.Items ?? []; -} - -async function getNextUp( - api: Api, - userId: string | undefined, - seriesId: string | undefined, -) { - if (!userId || !seriesId) return null; - const response = await getTvShowsApi(api).getNextUp({ - userId, - seriesId, - limit: 1, - }); - return response.data.Items?.[0] ?? null; -} diff --git a/components/home/InfiniteScrollingCollectionList.tsx b/components/home/InfiniteScrollingCollectionList.tsx index 1b45f2d7..30464b63 100644 --- a/components/home/InfiniteScrollingCollectionList.tsx +++ b/components/home/InfiniteScrollingCollectionList.tsx @@ -13,6 +13,7 @@ import { } from "react-native"; import { Text } from "@/components/common/Text"; import MoviePoster from "@/components/posters/MoviePoster"; +import { Colors } from "../../constants/Colors"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { ItemCardText } from "../ItemCardText"; @@ -35,7 +36,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ queryFn, queryKey, hideIfEmpty = false, - pageSize = 20, + pageSize = 10, ...props }) => { const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = @@ -52,9 +53,9 @@ export const InfiniteScrollingCollectionList: React.FC = ({ return allPages.length * pageSize; }, initialPageParam: 0, - staleTime: 0, - refetchOnMount: true, - refetchOnWindowFocus: true, + staleTime: 60 * 1000, // 1 minute + refetchOnMount: false, + refetchOnWindowFocus: false, refetchOnReconnect: true, }); @@ -179,8 +180,13 @@ export const InfiniteScrollingCollectionList: React.FC = ({ ))} {/* Loading indicator for next page */} {isFetchingNextPage && ( - - + + )} diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index dc18b464..54542c44 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { ScrollView, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import MoviePoster from "@/components/posters/MoviePoster"; +import { useInView } from "@/hooks/useInView"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { ItemCardText } from "../ItemCardText"; @@ -21,6 +22,8 @@ interface Props extends ViewProps { queryFn: QueryFunction; hideIfEmpty?: boolean; isOffline?: boolean; + scrollY?: number; // For lazy loading + enableLazyLoading?: boolean; // Enable/disable lazy loading } export const ScrollingCollectionList: React.FC = ({ @@ -31,33 +34,44 @@ export const ScrollingCollectionList: React.FC = ({ queryKey, hideIfEmpty = false, isOffline = false, + scrollY = 0, + enableLazyLoading = false, ...props }) => { + const { ref, isInView, onLayout } = useInView(scrollY, { + enabled: enableLazyLoading, + }); + const { data, isLoading } = useQuery({ queryKey: queryKey, queryFn, - staleTime: 0, - refetchOnMount: true, - refetchOnWindowFocus: true, + staleTime: 60 * 1000, // 1 minute + refetchOnMount: false, + refetchOnWindowFocus: false, refetchOnReconnect: true, + enabled: enableLazyLoading ? isInView : true, }); const { t } = useTranslation(); - if (hideIfEmpty === true && data?.length === 0) return null; + // Show skeleton if loading OR if lazy loading is enabled and not in view yet + const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView); + + if (hideIfEmpty === true && data?.length === 0 && !shouldShowSkeleton) + return null; if (disabled || !title) return null; return ( - + {title} - {isLoading === false && data?.length === 0 && ( + {!shouldShowSkeleton && data?.length === 0 && ( {t("home.no_items")} )} - {isLoading ? ( + {shouldShowSkeleton ? ( ; + scrollY?: number; // For lazy loading + enableLazyLoading?: boolean; // Enable/disable lazy loading } export const MediaListSection: React.FC = ({ queryFn, queryKey, + scrollY = 0, + enableLazyLoading = false, ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const { ref, isInView, onLayout } = useInView(scrollY, { + enabled: enableLazyLoading, + }); + const { data: collection } = useQuery({ queryKey, queryFn, - staleTime: 0, + staleTime: 60 * 1000, // 1 minute + enabled: enableLazyLoading ? isInView : true, }); const fetchItems = useCallback( @@ -60,7 +70,7 @@ export const MediaListSection: React.FC = ({ if (!collection) return null; return ( - + {collection.Name} diff --git a/eas.json b/eas.json index 56bc8f4c..1504100a 100644 --- a/eas.json +++ b/eas.json @@ -45,14 +45,14 @@ }, "production": { "environment": "production", - "channel": "0.47.0", + "channel": "0.47.1", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.47.0", + "channel": "0.47.1", "android": { "buildType": "apk", "image": "latest" @@ -60,7 +60,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.47.0", + "channel": "0.47.1", "android": { "buildType": "apk", "image": "latest" diff --git a/hooks/useInView.ts b/hooks/useInView.ts new file mode 100644 index 00000000..7dc17ac1 --- /dev/null +++ b/hooks/useInView.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { type LayoutRectangle, useWindowDimensions } from "react-native"; + +interface UseInViewOptions { + threshold?: number; // Distance in pixels before component is considered "in view" + enabled?: boolean; // Allow disabling the hook +} + +interface UseInViewReturn { + ref: (node: any) => void; + isInView: boolean; + onLayout: () => void; +} + +export const useInView = ( + scrollY: number = 0, + options: UseInViewOptions = {}, +): UseInViewReturn => { + const { threshold = 400, enabled = true } = options; + const { height: windowHeight } = useWindowDimensions(); + const [layout, setLayout] = useState(null); + const [hasBeenInView, setHasBeenInView] = useState(false); + const nodeRef = useRef(null); + + const ref = useCallback((node: any) => { + nodeRef.current = node; + }, []); + + const onLayout = useCallback(() => { + if (!nodeRef.current) return; + + // Use measure to get absolute position + nodeRef.current.measure( + ( + _x: number, + _y: number, + width: number, + height: number, + pageX: number, + pageY: number, + ) => { + setLayout({ x: pageX, y: pageY, width, height }); + }, + ); + }, []); + + useEffect(() => { + if (!enabled || hasBeenInView || !layout) return; + + // Calculate if the section is in view or about to be + const sectionTop = layout.y; + const viewportBottom = scrollY + windowHeight; + + // Check if section is within threshold distance of viewport + const isNearView = viewportBottom + threshold >= sectionTop; + + if (isNearView) { + setHasBeenInView(true); + } + }, [scrollY, windowHeight, threshold, layout, hasBeenInView, enabled]); + + return { + ref, + isInView: hasBeenInView, + onLayout, + }; +}; diff --git a/hooks/useNetworkStatus.ts b/hooks/useNetworkStatus.ts index 5acee27e..680a29d9 100644 --- a/hooks/useNetworkStatus.ts +++ b/hooks/useNetworkStatus.ts @@ -1,58 +1,2 @@ -import NetInfo from "@react-native-community/netinfo"; -import { useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; -import { apiAtom } from "@/providers/JellyfinProvider"; - -async function checkApiReachable(basePath?: string): Promise { - if (!basePath) return false; - try { - const response = await fetch(basePath, { method: "HEAD" }); - return response.ok; - } catch { - return false; - } -} - -export function useNetworkStatus() { - const [isConnected, setIsConnected] = useState(false); - const [serverConnected, setServerConnected] = useState(null); - const [loading, setLoading] = useState(false); - const [api] = useAtom(apiAtom); - - const validateConnection = useCallback(async () => { - if (!api?.basePath) return false; - const reachable = await checkApiReachable(api.basePath); - setServerConnected(reachable); - return reachable; - }, [api?.basePath]); - - const retryCheck = useCallback(async () => { - setLoading(true); - await validateConnection(); - setLoading(false); - }, [validateConnection]); - - useEffect(() => { - const unsubscribe = NetInfo.addEventListener(async (state) => { - setIsConnected(!!state.isConnected); - if (state.isConnected) { - await validateConnection(); - } else { - setServerConnected(false); - } - }); - - // Initial check: wait for NetInfo first - NetInfo.fetch().then((state) => { - if (state.isConnected) { - validateConnection(); - } else { - setServerConnected(false); - } - }); - - return () => unsubscribe(); - }, [validateConnection]); - - return { isConnected, serverConnected, loading, retryCheck }; -} +// Re-export from provider to maintain backward compatibility +export { useNetworkStatus } from "@/providers/NetworkStatusProvider"; diff --git a/package.json b/package.json index c0f711f7..b627cfdc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@react-navigation/material-top-tabs": "^7.2.14", "@react-navigation/native": "^7.0.14", "@shopify/flash-list": "2.0.2", - "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query": "^5.90.7", "axios": "^1.7.9", "expo": "^54.0.23", "expo-application": "~7.0.5", diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index d3775fc6..c19c60ce 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.47.0" }, + clientInfo: { name: "Streamyfin", version: "0.47.1" }, deviceInfo: { name: deviceName, id, @@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.47.0"`, + }, DeviceId="${deviceId}", Version="0.47.1"`, }; }, [deviceId]); diff --git a/providers/NetworkStatusProvider.tsx b/providers/NetworkStatusProvider.tsx new file mode 100644 index 00000000..d44caac6 --- /dev/null +++ b/providers/NetworkStatusProvider.tsx @@ -0,0 +1,92 @@ +import NetInfo from "@react-native-community/netinfo"; +import { useAtom } from "jotai"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { apiAtom } from "@/providers/JellyfinProvider"; + +interface NetworkStatusContextType { + isConnected: boolean; + serverConnected: boolean | null; + loading: boolean; + retryCheck: () => Promise; +} + +const NetworkStatusContext = createContext( + null, +); + +async function checkApiReachable(basePath?: string): Promise { + if (!basePath) return false; + try { + const response = await fetch(basePath, { method: "HEAD" }); + return response.ok; + } catch { + return false; + } +} + +export function NetworkStatusProvider({ children }: { children: ReactNode }) { + const [isConnected, setIsConnected] = useState(false); + const [serverConnected, setServerConnected] = useState(null); + const [loading, setLoading] = useState(false); + const [api] = useAtom(apiAtom); + + const validateConnection = useCallback(async () => { + if (!api?.basePath) return false; + const reachable = await checkApiReachable(api.basePath); + setServerConnected(reachable); + return reachable; + }, [api?.basePath]); + + const retryCheck = useCallback(async () => { + setLoading(true); + await validateConnection(); + setLoading(false); + }, [validateConnection]); + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener(async (state) => { + setIsConnected(!!state.isConnected); + if (state.isConnected) { + await validateConnection(); + } else { + setServerConnected(false); + } + }); + + // Initial check + NetInfo.fetch().then((state) => { + if (state.isConnected) { + validateConnection(); + } else { + setServerConnected(false); + } + }); + + return () => unsubscribe(); + }, [validateConnection]); + + return ( + + {children} + + ); +} + +export function useNetworkStatus(): NetworkStatusContextType { + const context = useContext(NetworkStatusContext); + if (!context) { + throw new Error( + "useNetworkStatus must be used within NetworkStatusProvider", + ); + } + return context; +}