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

This commit is contained in:
Fredrik Burmester
2025-11-12 21:30:20 +01:00
parent 7a5f0b52b6
commit f7da29b9c9
15 changed files with 421 additions and 299 deletions

View File

@@ -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",

View File

@@ -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>
); );

View File

@@ -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",

View File

@@ -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 ||

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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
View 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,
};
};

View File

@@ -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 };
}

View File

@@ -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",

View File

@@ -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]);

View 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;
}