mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00: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:
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.47.0",
|
"version": "0.47.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 83,
|
"versionCode": 84,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
|
|||||||
113
app/_layout.tsx
113
app/_layout.tsx
@@ -15,6 +15,7 @@ import {
|
|||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
JellyfinProvider,
|
JellyfinProvider,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -338,63 +339,65 @@ function Layout() {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<PlaySettingsProvider>
|
<NetworkStatusProvider>
|
||||||
<LogProvider>
|
<PlaySettingsProvider>
|
||||||
<WebSocketProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<GlobalModalProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<SystemBars style='light' hidden={false} />
|
||||||
<Stack.Screen
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
name='(auth)/(tabs)'
|
<Stack.Screen
|
||||||
options={{
|
name='(auth)/(tabs)'
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
header: () => null,
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/player'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
|
closeButton
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<GlobalModal />
|
||||||
name='(auth)/player'
|
</ThemeProvider>
|
||||||
options={{
|
</BottomSheetModalProvider>
|
||||||
headerShown: false,
|
</GlobalModalProvider>
|
||||||
title: "",
|
</DownloadProvider>
|
||||||
header: () => null,
|
</WebSocketProvider>
|
||||||
}}
|
</LogProvider>
|
||||||
/>
|
</PlaySettingsProvider>
|
||||||
<Stack.Screen
|
</NetworkStatusProvider>
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
<GlobalModal />
|
|
||||||
</ThemeProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</GlobalModalProvider>
|
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
|
||||||
</LogProvider>
|
|
||||||
</PlaySettingsProvider>
|
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
2
bun.lock
2
bun.lock
@@ -15,7 +15,7 @@
|
|||||||
"@react-navigation/material-top-tabs": "^7.2.14",
|
"@react-navigation/material-top-tabs": "^7.2.14",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "^54.0.23",
|
"expo": "^54.0.23",
|
||||||
"expo-application": "~7.0.5",
|
"expo-application": "~7.0.5",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function InfiniteHorizontalScroll({
|
|||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
if (
|
if (
|
||||||
!lastPage?.Items ||
|
!lastPage?.Items ||
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -28,7 +27,7 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -39,12 +38,13 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "ScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
title?: string;
|
title?: string;
|
||||||
queryKey: (string | undefined | null)[];
|
queryKey: (string | undefined | null)[];
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
|
pageSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSectionType = {
|
type MediaListSectionType = {
|
||||||
@@ -53,7 +53,7 @@ type MediaListSectionType = {
|
|||||||
queryFn: QueryFunction<BaseItemDto>;
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -74,6 +74,11 @@ export const Home = () => {
|
|||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("scrollY", scrollY);
|
||||||
|
}, [scrollY]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
@@ -182,26 +187,31 @@ export const Home = () => {
|
|||||||
queryKey: string[],
|
queryKey: string[],
|
||||||
includeItemTypes: BaseItemKind[],
|
includeItemTypes: BaseItemKind[],
|
||||||
parentId: string | undefined,
|
parentId: string | undefined,
|
||||||
): ScrollingCollectionListSection => ({
|
pageSize: number = 10,
|
||||||
|
): InfiniteScrollingCollectionListSection => ({
|
||||||
title,
|
title,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
return (
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
|
const allData =
|
||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 20,
|
limit: 100, // Fetch a larger set for pagination
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
).data || []
|
).data || [];
|
||||||
);
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
pageSize,
|
||||||
}),
|
}),
|
||||||
[api, user?.Id],
|
[api, user?.Id],
|
||||||
);
|
);
|
||||||
@@ -226,6 +236,7 @@ export const Home = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
c.Id,
|
c.Id,
|
||||||
|
10,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,69 +244,56 @@ export const Home = () => {
|
|||||||
{
|
{
|
||||||
title: t("home.continue_watching"),
|
title: t("home.continue_watching"),
|
||||||
queryKey: ["home", "resumeItems"],
|
queryKey: ["home", "resumeItems"],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
fields: ["Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("home.next_up"),
|
title: t("home.next_up"),
|
||||||
queryKey: ["home", "nextUp-all"],
|
queryKey: ["home", "nextUp-all"],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: 20,
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
...latestMediaViews,
|
...latestMediaViews,
|
||||||
{
|
{
|
||||||
title: t("home.suggested_movies"),
|
title: t("home.suggested_movies"),
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
mediaType: ["Video"],
|
mediaType: ["Video"],
|
||||||
type: ["Movie"],
|
type: ["Movie"],
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
},
|
pageSize: 10,
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return ss;
|
return ss;
|
||||||
@@ -306,14 +304,16 @@ export const Home = () => {
|
|||||||
const ss: Section[] = [];
|
const ss: Section[] = [];
|
||||||
settings.home.sections.forEach((section, index) => {
|
settings.home.sections.forEach((section, index) => {
|
||||||
const id = section.title || `section-${index}`;
|
const id = section.title || `section-${index}`;
|
||||||
|
const pageSize = 10;
|
||||||
ss.push({
|
ss.push({
|
||||||
title: t(`${id}`),
|
title: t(`${id}`),
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
queryFn: async () => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (section.items) {
|
if (section.items) {
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: section.items?.limit || 25,
|
startIndex: pageParam,
|
||||||
|
limit: section.items?.limit || pageSize,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
sortBy: section.items?.sortBy,
|
sortBy: section.items?.sortBy,
|
||||||
@@ -327,7 +327,8 @@ export const Home = () => {
|
|||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: section.nextUp?.limit || 25,
|
startIndex: pageParam,
|
||||||
|
limit: section.nextUp?.limit || pageSize,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
@@ -335,20 +336,31 @@ export const Home = () => {
|
|||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
}
|
}
|
||||||
if (section.latest) {
|
if (section.latest) {
|
||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
userId: user?.Id,
|
const allData =
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
(
|
||||||
limit: section.latest?.limit || 25,
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
isPlayed: section.latest?.isPlayed,
|
userId: user?.Id,
|
||||||
groupItems: section.latest?.groupItems,
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
});
|
limit: section.latest?.limit || 100, // Fetch larger set
|
||||||
return response.data || [];
|
isPlayed: section.latest?.isPlayed,
|
||||||
|
groupItems: section.latest?.groupItems,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
}
|
}
|
||||||
if (section.custom) {
|
if (section.custom) {
|
||||||
const response = await api.get<BaseItemDtoQueryResult>(
|
const response = await api.get<BaseItemDtoQueryResult>(
|
||||||
section.custom.endpoint,
|
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 || {},
|
headers: section.custom.headers || {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -356,12 +368,13 @@ export const Home = () => {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: section?.orientation || "vertical",
|
orientation: section?.orientation || "vertical",
|
||||||
|
pageSize,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, settings?.home?.sections]);
|
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
@@ -442,6 +455,10 @@ export const Home = () => {
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
onScroll={(event) => {
|
||||||
|
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={16}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={loading}
|
refreshing={loading}
|
||||||
@@ -461,15 +478,16 @@ export const Home = () => {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
if (section.type === "ScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
<ScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
key={index}
|
key={index}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
|
pageSize={section.pageSize}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -479,6 +497,8 @@ export const Home = () => {
|
|||||||
key={index}
|
key={index}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
|
scrollY={scrollY}
|
||||||
|
enableLazyLoading={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -488,28 +508,3 @@ export const Home = () => {
|
|||||||
</ScrollView>
|
</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 { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -30,7 +29,7 @@ import Animated, {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -42,12 +41,13 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "ScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
title?: string;
|
title?: string;
|
||||||
queryKey: (string | undefined | null)[];
|
queryKey: (string | undefined | null)[];
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
|
pageSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSectionType = {
|
type MediaListSectionType = {
|
||||||
@@ -56,7 +56,7 @@ type MediaListSectionType = {
|
|||||||
queryFn: QueryFunction<BaseItemDto>;
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||||
|
|
||||||
export const HomeWithCarousel = () => {
|
export const HomeWithCarousel = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -79,6 +79,7 @@ export const HomeWithCarousel = () => {
|
|||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
@@ -187,26 +188,31 @@ export const HomeWithCarousel = () => {
|
|||||||
queryKey: string[],
|
queryKey: string[],
|
||||||
includeItemTypes: BaseItemKind[],
|
includeItemTypes: BaseItemKind[],
|
||||||
parentId: string | undefined,
|
parentId: string | undefined,
|
||||||
): ScrollingCollectionListSection => ({
|
pageSize: number = 10,
|
||||||
|
): InfiniteScrollingCollectionListSection => ({
|
||||||
title,
|
title,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
return (
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
|
const allData =
|
||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 20,
|
limit: 100, // Fetch a larger set for pagination
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
).data || []
|
).data || [];
|
||||||
);
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
pageSize,
|
||||||
}),
|
}),
|
||||||
[api, user?.Id],
|
[api, user?.Id],
|
||||||
);
|
);
|
||||||
@@ -231,6 +237,7 @@ export const HomeWithCarousel = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
c.Id,
|
c.Id,
|
||||||
|
10,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,69 +245,56 @@ export const HomeWithCarousel = () => {
|
|||||||
{
|
{
|
||||||
title: t("home.continue_watching"),
|
title: t("home.continue_watching"),
|
||||||
queryKey: ["home", "resumeItems"],
|
queryKey: ["home", "resumeItems"],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
fields: ["Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("home.next_up"),
|
title: t("home.next_up"),
|
||||||
queryKey: ["home", "nextUp-all"],
|
queryKey: ["home", "nextUp-all"],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: 20,
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
...latestMediaViews,
|
...latestMediaViews,
|
||||||
{
|
{
|
||||||
title: t("home.suggested_movies"),
|
title: t("home.suggested_movies"),
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
mediaType: ["Video"],
|
mediaType: ["Video"],
|
||||||
type: ["Movie"],
|
type: ["Movie"],
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
},
|
pageSize: 10,
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return ss;
|
return ss;
|
||||||
@@ -311,14 +305,16 @@ export const HomeWithCarousel = () => {
|
|||||||
const ss: Section[] = [];
|
const ss: Section[] = [];
|
||||||
settings.home.sections.forEach((section, index) => {
|
settings.home.sections.forEach((section, index) => {
|
||||||
const id = section.title || `section-${index}`;
|
const id = section.title || `section-${index}`;
|
||||||
|
const pageSize = 10;
|
||||||
ss.push({
|
ss.push({
|
||||||
title: t(`${id}`),
|
title: t(`${id}`),
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
queryFn: async () => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (section.items) {
|
if (section.items) {
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: section.items?.limit || 25,
|
startIndex: pageParam,
|
||||||
|
limit: section.items?.limit || pageSize,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
sortBy: section.items?.sortBy,
|
sortBy: section.items?.sortBy,
|
||||||
@@ -332,7 +328,8 @@ export const HomeWithCarousel = () => {
|
|||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: section.nextUp?.limit || 25,
|
startIndex: pageParam,
|
||||||
|
limit: section.nextUp?.limit || pageSize,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
@@ -340,20 +337,31 @@ export const HomeWithCarousel = () => {
|
|||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
}
|
}
|
||||||
if (section.latest) {
|
if (section.latest) {
|
||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
userId: user?.Id,
|
const allData =
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
(
|
||||||
limit: section.latest?.limit || 25,
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
isPlayed: section.latest?.isPlayed,
|
userId: user?.Id,
|
||||||
groupItems: section.latest?.groupItems,
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
});
|
limit: section.latest?.limit || 100, // Fetch larger set
|
||||||
return response.data || [];
|
isPlayed: section.latest?.isPlayed,
|
||||||
|
groupItems: section.latest?.groupItems,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
}
|
}
|
||||||
if (section.custom) {
|
if (section.custom) {
|
||||||
const response = await api.get<BaseItemDtoQueryResult>(
|
const response = await api.get<BaseItemDtoQueryResult>(
|
||||||
section.custom.endpoint,
|
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 || {},
|
headers: section.custom.headers || {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -361,12 +369,13 @@ export const HomeWithCarousel = () => {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: section?.orientation || "vertical",
|
orientation: section?.orientation || "vertical",
|
||||||
|
pageSize,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, settings?.home?.sections]);
|
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
@@ -453,6 +462,9 @@ export const HomeWithCarousel = () => {
|
|||||||
overScrollMode='never'
|
overScrollMode='never'
|
||||||
style={{ marginTop: -headerOverlayOffset }}
|
style={{ marginTop: -headerOverlayOffset }}
|
||||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
||||||
|
onScroll={(event) => {
|
||||||
|
setScrollY(event.nativeEvent.contentOffset.y);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
||||||
<View
|
<View
|
||||||
@@ -465,15 +477,16 @@ export const HomeWithCarousel = () => {
|
|||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4'>
|
<View className='flex flex-col space-y-4'>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
if (section.type === "ScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
<ScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
key={index}
|
key={index}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
|
pageSize={section.pageSize}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -483,6 +496,8 @@ export const HomeWithCarousel = () => {
|
|||||||
key={index}
|
key={index}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
|
scrollY={scrollY}
|
||||||
|
enableLazyLoading={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -494,28 +509,3 @@ export const HomeWithCarousel = () => {
|
|||||||
</Animated.ScrollView>
|
</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";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { Colors } from "../../constants/Colors";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -35,7 +36,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
pageSize = 20,
|
pageSize = 10,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
@@ -52,9 +53,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
return allPages.length * pageSize;
|
return allPages.length * pageSize;
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: true,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,8 +180,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
))}
|
))}
|
||||||
{/* Loading indicator for next page */}
|
{/* Loading indicator for next page */}
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<View className='justify-center items-center w-16'>
|
<View
|
||||||
<ActivityIndicator size='small' color='#6366f1' />
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
marginTop: orientation === "horizontal" ? 37 : 70,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='small' color={Colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ScrollView, View, type ViewProps } from "react-native";
|
import { ScrollView, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { useInView } from "@/hooks/useInView";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -21,6 +22,8 @@ interface Props extends ViewProps {
|
|||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
hideIfEmpty?: boolean;
|
hideIfEmpty?: boolean;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
|
scrollY?: number; // For lazy loading
|
||||||
|
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -31,33 +34,44 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
|
scrollY = 0,
|
||||||
|
enableLazyLoading = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const { ref, isInView, onLayout } = useInView(scrollY, {
|
||||||
|
enabled: enableLazyLoading,
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: true,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
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;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View ref={ref} onLayout={onLayout} {...props}>
|
||||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{isLoading === false && data?.length === 0 && (
|
{!shouldShowSkeleton && data?.length === 0 && (
|
||||||
<View className='px-4'>
|
<View className='px-4'>
|
||||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{isLoading ? (
|
{shouldShowSkeleton ? (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row gap-2 px-4
|
flex flex-row gap-2 px-4
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { useInView } from "@/hooks/useInView";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
|
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -21,20 +22,29 @@ import MoviePoster from "../posters/MoviePoster";
|
|||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
|
scrollY?: number; // For lazy loading
|
||||||
|
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaListSection: React.FC<Props> = ({
|
export const MediaListSection: React.FC<Props> = ({
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
|
scrollY = 0,
|
||||||
|
enableLazyLoading = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { ref, isInView, onLayout } = useInView(scrollY, {
|
||||||
|
enabled: enableLazyLoading,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
@@ -60,7 +70,7 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View ref={ref} onLayout={onLayout} {...props}>
|
||||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||||
{collection.Name}
|
{collection.Name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
6
eas.json
6
eas.json
@@ -45,14 +45,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.0",
|
"channel": "0.47.1",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.0",
|
"channel": "0.47.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.0",
|
"channel": "0.47.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
67
hooks/useInView.ts
Normal file
67
hooks/useInView.ts
Normal file
@@ -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<LayoutRectangle | null>(null);
|
||||||
|
const [hasBeenInView, setHasBeenInView] = useState(false);
|
||||||
|
const nodeRef = useRef<any>(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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,58 +1,2 @@
|
|||||||
import NetInfo from "@react-native-community/netinfo";
|
// Re-export from provider to maintain backward compatibility
|
||||||
import { useAtom } from "jotai";
|
export { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
async function checkApiReachable(basePath?: string): Promise<boolean> {
|
|
||||||
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<boolean | null>(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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@react-navigation/material-top-tabs": "^7.2.14",
|
"@react-navigation/material-top-tabs": "^7.2.14",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "^54.0.23",
|
"expo": "^54.0.23",
|
||||||
"expo-application": "~7.0.5",
|
"expo-application": "~7.0.5",
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.47.0" },
|
clientInfo: { name: "Streamyfin", version: "0.47.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.47.0"`,
|
}, DeviceId="${deviceId}", Version="0.47.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
92
providers/NetworkStatusProvider.tsx
Normal file
92
providers/NetworkStatusProvider.tsx
Normal file
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NetworkStatusContext = createContext<NetworkStatusContextType | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function checkApiReachable(basePath?: string): Promise<boolean> {
|
||||||
|
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<boolean | null>(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 (
|
||||||
|
<NetworkStatusContext.Provider
|
||||||
|
value={{ isConnected, serverConnected, loading, retryCheck }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NetworkStatusContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNetworkStatus(): NetworkStatusContextType {
|
||||||
|
const context = useContext(NetworkStatusContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useNetworkStatus must be used within NetworkStatusProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user