Compare commits

..

3 Commits

Author SHA1 Message Date
Uruk
5e81576c68 feat(home): adds pull to refresh functionality
Adds pull-to-refresh functionality on the home screen.

This change enhances user experience by allowing users to
manually refresh the home screen content. It also ensures
that the loading indicator is correctly managed during
refresh operations, using a try-finally block.
2026-02-11 19:09:01 +01:00
Uruk
a16d73688f feat(home): refresh data on app foreground
adds a hook that refetches home data when the app
returns to the foreground or starts from a cold start.

this ensures the home screen always displays the
most up-to-date content.
2026-02-10 23:10:07 +01:00
Uruk
ac0d3fcb86 refactor(item): improves top people display logic
Improves the logic for displaying the top people in the item sections.

The previous implementation simply sliced the people array which
could result in duplicate people being displayed.

This change ensures that the top people displayed are unique by
filtering duplicates based on their id, limiting the number of
displayed people to three.
2026-02-10 19:13:44 +01:00
6 changed files with 89 additions and 21 deletions

View File

@@ -14,7 +14,7 @@
"@gorhom/bottom-sheet": "5.2.8", "@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^11.4.1",
"@react-navigation/material-top-tabs": "7.4.13", "@react-navigation/material-top-tabs": "7.4.9",
"@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/query-sync-storage-persister": "^5.90.18", "@tanstack/query-sync-storage-persister": "^5.90.18",
@@ -566,9 +566,9 @@
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="], "@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
"@react-navigation/elements": ["@react-navigation/elements@2.9.5", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.28", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-iHZU8rRN1014Upz73AqNVXDvSMZDh5/ktQ1CMe21rdgnOY79RWtHHBp9qOS3VtqlUVYGkuX5GEw5mDt4tKdl0g=="], "@react-navigation/elements": ["@react-navigation/elements@2.9.2", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-J1GltOAGowNLznEphV/kr4zs0U7mUBO1wVA2CqpkN8ePBsoxrAmsd+T5sEYUCXN9KgTDFvc6IfcDqrGSQngd/g=="],
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.13", "", { "dependencies": { "@react-navigation/elements": "^2.9.5", "color": "^4.2.3", "react-native-tab-view": "^4.2.2" }, "peerDependencies": { "@react-navigation/native": "^7.1.28", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-evQPmk8bDdY4yksV2Onko+g5z9BwuBdjZEtypuEeshXXTcj+G4Vw6zDIHaeY8jg1JMEFp1I4fPaAeEzSms5tBw=="], "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.9", "", { "dependencies": { "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-oYpdTfa2D1Tn0HJER9dRCR260agKGgYe+ydSHt3RIsJ9sLg8hU7ntKYWo1FnEC/Nsv1/N1u/tRst7ZpQRjjl4A=="],
"@react-navigation/native": ["@react-navigation/native@7.1.19", "", { "dependencies": { "@react-navigation/core": "^7.13.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-fM7q8di4Q8sp2WUhiUWOe7bEDRyRhbzsKQOd5N2k+lHeCx3UncsRYuw4Q/KN0EovM3wWKqMMmhy/YWuEO04kgw=="], "@react-navigation/native": ["@react-navigation/native@7.1.19", "", { "dependencies": { "@react-navigation/core": "^7.13.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-fM7q8di4Q8sp2WUhiUWOe7bEDRyRhbzsKQOd5N2k+lHeCx3UncsRYuw4Q/KN0EovM3wWKqMMmhy/YWuEO04kgw=="],
@@ -1692,7 +1692,7 @@
"react-native-svg": ["react-native-svg@15.12.1", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g=="], "react-native-svg": ["react-native-svg@15.12.1", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g=="],
"react-native-tab-view": ["react-native-tab-view@4.2.2", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-NXtrG6OchvbGjsvbySJGVocXxo4Y2vA17ph4rAaWtA2jh+AasD8OyikKBRg2SmllEfeQ+GEhcKe8kulHv8BhTg=="], "react-native-tab-view": ["react-native-tab-view@4.2.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="],
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],

View File

@@ -35,6 +35,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { useIntroSheet } from "@/providers/IntroSheetProvider";
@@ -107,6 +108,9 @@ export const Home = () => {
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const hasDownloads = useMemo(() => { const hasDownloads = useMemo(() => {
if (Platform.isTV) return false; if (Platform.isTV) return false;
return downloadedItems.length > 0; return downloadedItems.length > 0;
@@ -196,10 +200,13 @@ export const Home = () => {
const refetch = async () => { const refetch = async () => {
setLoading(true); setLoading(true);
try {
setLoadedSections(new Set()); setLoadedSections(new Set());
await refreshStreamyfinPluginSettings(); await refreshStreamyfinPluginSettings();
await invalidateCache(); await invalidateCache();
} finally {
setLoading(false); setLoading(false);
}
}; };
const createCollectionConfig = useCallback( const createCollectionConfig = useCallback(

View File

@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Platform, Platform,
RefreshControl,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
@@ -37,6 +38,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -67,8 +69,8 @@ export const HomeWithCarousel = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings(); const { settings, refreshStreamyfinPluginSettings } = useSettings();
const [loading, setLoading] = useState(false);
const headerOverlayOffset = Platform.isTV ? 0 : 60; const headerOverlayOffset = Platform.isTV ? 0 : 60;
const navigation = useNavigation(); const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>(); const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -91,6 +93,19 @@ export const HomeWithCarousel = () => {
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const refetch = async () => {
setLoading(true);
try {
await refreshStreamyfinPluginSettings();
await invalidateCache();
} finally {
setLoading(false);
}
};
const hasDownloads = useMemo(() => { const hasDownloads = useMemo(() => {
if (Platform.isTV) return false; if (Platform.isTV) return false;
return downloadedItems.length > 0; return downloadedItems.length > 0;
@@ -178,13 +193,6 @@ export const HomeWithCarousel = () => {
); );
}, [userViews]); }, [userViews]);
const _refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback( const createCollectionConfig = useCallback(
( (
title: string, title: string,
@@ -540,8 +548,18 @@ export const HomeWithCarousel = () => {
nestedScrollEnabled nestedScrollEnabled
contentInsetAdjustmentBehavior='never' contentInsetAdjustmentBehavior='never'
scrollEventThrottle={16} scrollEventThrottle={16}
bounces={false} bounces={!Platform.isTV}
overScrollMode='never' overScrollMode='never'
refreshControl={
!Platform.isTV ? (
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white'
colors={["white"]}
/>
) : undefined
}
style={{ marginTop: -headerOverlayOffset }} style={{ marginTop: -headerOverlayOffset }}
contentContainerStyle={{ paddingTop: headerOverlayOffset }} contentContainerStyle={{ paddingTop: headerOverlayOffset }}
onScroll={(event) => { onScroll={(event) => {
@@ -570,7 +588,7 @@ export const HomeWithCarousel = () => {
settings.streamyStatsPromotedWatchlists; settings.streamyStatsPromotedWatchlists;
const streamystatsSections = const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent ? ( index === streamystatsIndex && hasStreamystatsContent ? (
<> <View key='streamystats-sections'>
{settings.streamyStatsMovieRecommendations && ( {settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations <StreamystatsRecommendations
title={t( title={t(
@@ -590,7 +608,7 @@ export const HomeWithCarousel = () => {
{settings.streamyStatsPromotedWatchlists && ( {settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists /> <StreamystatsPromotedWatchlists />
)} )}
</> </View>
) : null; ) : null;
if (section.type === "InfiniteScrollingCollectionList") { if (section.type === "InfiniteScrollingCollectionList") {

View File

@@ -37,7 +37,18 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return { ...item, People: people } as BaseItemDto; return { ...item, People: people } as BaseItemDto;
}, [item, people]); }, [item, people]);
const topPeople = useMemo(() => people.slice(0, 3), [people]); const topPeople = useMemo(() => {
const seen = new Set<string>();
const unique: BaseItemPerson[] = [];
for (const person of people) {
if (person.Id && !seen.has(person.Id)) {
seen.add(person.Id);
unique.push(person);
}
if (unique.length >= 3) break;
}
return unique;
}, [people]);
const renderActorSection = useCallback( const renderActorSection = useCallback(
(person: BaseItemPerson, idx: number, total: number) => { (person: BaseItemPerson, idx: number, total: number) => {

View File

@@ -0,0 +1,32 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { AppState } from "react-native";
/**
* Refetches active home queries on mount (cold start) and whenever
* the app returns to the foreground from background.
*/
export function useRefetchHomeOnForeground() {
const queryClient = useQueryClient();
useEffect(() => {
// On mount (cold start), use cancelRefetch: false so we don't cancel
// an in-flight initial fetch that React Query already started.
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: false },
);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
// On foreground return, force a fresh refetch
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: true },
);
}
});
return () => subscription.remove();
}, [queryClient]);
}

View File

@@ -34,7 +34,7 @@
"@gorhom/bottom-sheet": "5.2.8", "@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^11.4.1",
"@react-navigation/material-top-tabs": "7.4.13", "@react-navigation/material-top-tabs": "7.4.9",
"@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/query-sync-storage-persister": "^5.90.18", "@tanstack/query-sync-storage-persister": "^5.90.18",