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 (
-
-
-
-
-
-
-
-
-
- null,
+
+
+
+
+
+
+
+
+
+
+ null,
+ }}
+ />
+ null,
+ }}
+ />
+
+
+
+
- null,
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
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;
+}