mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-21 00:04:42 +01:00
fix: better api calls
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
This commit is contained in:
@@ -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 ||
|
||||
|
||||
@@ -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<BaseItemDto[]>;
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
@@ -53,7 +53,7 @@ type MediaListSectionType = {
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
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<BaseItemDtoQueryResult>(
|
||||
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={
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
@@ -461,15 +478,16 @@ export const Home = () => {
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<BaseItemDto[]>;
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
@@ -56,7 +56,7 @@ type MediaListSectionType = {
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
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<BaseItemDtoQueryResult>(
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
||||
<View
|
||||
@@ -465,15 +477,16 @@ export const HomeWithCarousel = () => {
|
||||
>
|
||||
<View className='flex flex-col space-y-4'>
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
pageSize = 20,
|
||||
pageSize = 10,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
@@ -52,9 +53,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
))}
|
||||
{/* Loading indicator for next page */}
|
||||
{isFetchingNextPage && (
|
||||
<View className='justify-center items-center w-16'>
|
||||
<ActivityIndicator size='small' color='#6366f1' />
|
||||
<View
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
marginTop: orientation === "horizontal" ? 37 : 70,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='small' color={Colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
scrollY?: number; // For lazy loading
|
||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||
}
|
||||
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -31,33 +34,44 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
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 (
|
||||
<View {...props}>
|
||||
<View ref={ref} onLayout={onLayout} {...props}>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
{isLoading === false && data?.length === 0 && (
|
||||
{!shouldShowSkeleton && data?.length === 0 && (
|
||||
<View className='px-4'>
|
||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isLoading ? (
|
||||
{shouldShowSkeleton ? (
|
||||
<View
|
||||
className={`
|
||||
flex flex-row gap-2 px-4
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -21,20 +22,29 @@ import MoviePoster from "../posters/MoviePoster";
|
||||
interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
scrollY?: number; // For lazy loading
|
||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||
}
|
||||
|
||||
export const MediaListSection: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<View ref={ref} onLayout={onLayout} {...props}>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{collection.Name}
|
||||
</Text>
|
||||
|
||||
Reference in New Issue
Block a user