diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj
index 8af9d2f9..70862847 100644
--- a/ios/Nuvio.xcodeproj/project.pbxproj
+++ b/ios/Nuvio.xcodeproj/project.pbxproj
@@ -477,7 +477,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
- PRODUCT_NAME = Nuvio;
+ PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -508,8 +508,8 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
- PRODUCT_BUNDLE_IDENTIFIER = com.nuviohub.app;
- PRODUCT_NAME = Nuvio;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
+ PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist
index 8626ae2e..0644e7d8 100644
--- a/ios/Nuvio/Info.plist
+++ b/ios/Nuvio/Info.plist
@@ -1,101 +1,103 @@
-
- CADisableMinimumFrameDurationOnPhone
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleDisplayName
- Nuvio
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- $(PRODUCT_NAME)
- CFBundlePackageType
- $(PRODUCT_BUNDLE_PACKAGE_TYPE)
- CFBundleShortVersionString
- 1.2.8
- CFBundleSignature
- ????
- CFBundleURLTypes
-
-
- CFBundleURLSchemes
-
- nuvio
- com.nuvio.app
-
-
-
- CFBundleURLSchemes
-
- exp+nuvio
-
-
-
- CFBundleVersion
- 23
- LSMinimumSystemVersion
- 12.0
- LSRequiresIPhoneOS
-
- LSSupportsOpeningDocumentsInPlace
-
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
-
- NSBonjourServices
-
- _http._tcp
- _googlecast._tcp
- _CC1AD845._googlecast._tcp
-
- NSLocalNetworkUsageDescription
- Allow $(PRODUCT_NAME) to access your local network
- RCTNewArchEnabled
-
- RCTRootViewBackgroundColor
- 4278322180
- UIBackgroundModes
-
- audio
-
- UIFileSharingEnabled
-
- UILaunchStoryboardName
- SplashScreen
- UIRequiredDeviceCapabilities
-
- arm64
-
- UIRequiresFullScreen
-
- UIStatusBarStyle
- UIStatusBarStyleDefault
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UIUserInterfaceStyle
- Dark
- UIViewControllerBasedStatusBarAppearance
-
-
-
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Nuvio
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 1.2.8
+ CFBundleSignature
+ ????
+ CFBundleURLTypes
+
+
+ CFBundleURLSchemes
+
+ nuvio
+ com.nuvio.app
+
+
+
+ CFBundleURLSchemes
+
+ exp+nuvio
+
+
+
+ CFBundleVersion
+ 23
+ LSMinimumSystemVersion
+ 12.0
+ LSRequiresIPhoneOS
+
+ LSSupportsOpeningDocumentsInPlace
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSBonjourServices
+
+ _http._tcp
+ _googlecast._tcp
+ _CC1AD845._googlecast._tcp
+
+ NSLocalNetworkUsageDescription
+ Allow $(PRODUCT_NAME) to access your local network
+ NSMicrophoneUsageDescription
+ This app does not require microphone access.
+ RCTNewArchEnabled
+
+ RCTRootViewBackgroundColor
+ 4278322180
+ UIBackgroundModes
+
+ audio
+
+ UIFileSharingEnabled
+
+ UILaunchStoryboardName
+ SplashScreen
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UIRequiresFullScreen
+
+ UIStatusBarStyle
+ UIStatusBarStyleDefault
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIUserInterfaceStyle
+ Dark
+ UIViewControllerBasedStatusBarAppearance
+
+
+
\ No newline at end of file
diff --git a/ios/Nuvio/NuvioRelease.entitlements b/ios/Nuvio/NuvioRelease.entitlements
index 0c67376e..a0bc443f 100644
--- a/ios/Nuvio/NuvioRelease.entitlements
+++ b/ios/Nuvio/NuvioRelease.entitlements
@@ -1,5 +1,10 @@
-
-
+
+ aps-environment
+ development
+ com.apple.developer.associated-domains
+
+
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 6d140461..a6ff64ad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,7 +28,7 @@
"@react-navigation/native-stack": "^7.3.10",
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "~7.3.0",
- "@shopify/flash-list": "^2.1.0",
+ "@shopify/flash-list": "^2.2.0",
"@shopify/react-native-skia": "2.2.12",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
@@ -3616,9 +3616,9 @@
}
},
"node_modules/@shopify/flash-list": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.1.0.tgz",
- "integrity": "sha512-/EIQlptG456yM5o9qNmNsmaZEFEOGvG3WGyb6GUAxSLlcKUGlPUkPI2NLW5wQSDEY4xSRa5zocUI+9xwmsM4Kg==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.2.0.tgz",
+ "integrity": "sha512-mL61IofcfBNRZ/qazIf+pghGULkcZUQ7EZNldH1JBbIjtDb25ADSiQrt62ZTnRz0H5+bPFEZUmN9+WChHzX8pw==",
"license": "MIT",
"peerDependencies": {
"@babel/runtime": "*",
diff --git a/package.json b/package.json
index b828800e..77fc5b05 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"@react-navigation/native-stack": "^7.3.10",
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "~7.3.0",
- "@shopify/flash-list": "^2.1.0",
+ "@shopify/flash-list": "^2.2.0",
"@shopify/react-native-skia": "2.2.12",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx
index 65cc6f1d..ffbe0929 100644
--- a/src/components/home/AppleTVHero.tsx
+++ b/src/components/home/AppleTVHero.tsx
@@ -164,6 +164,7 @@ const AppleTVHero: React.FC = ({
const [logoHeights, setLogoHeights] = useState>({});
const autoPlayTimerRef = useRef(null);
const lastInteractionRef = useRef(Date.now());
+ const [initialLoadComplete, setInitialLoadComplete] = useState(false);
// Trailer state
const [trailerUrl, setTrailerUrl] = useState(null);
@@ -192,9 +193,18 @@ const AppleTVHero: React.FC = ({
const thumbnailOpacity = useSharedValue(1);
const trailerOpacity = useSharedValue(0);
const trailerMuted = settings?.trailerMuted ?? true;
+ const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in
// Animated style for trailer container - 60% height with zoom
const trailerContainerStyle = useAnimatedStyle(() => {
+ // Fade out trailer during drag with smooth curve (inverse of next image fade)
+ const dragFade = interpolate(
+ dragProgress.value,
+ [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1],
+ [1, 0.95, 0.88, 0.78, 0.65, 0.5, 0.35, 0.22, 0.08, 0],
+ Extrapolation.CLAMP
+ );
+
return {
position: 'absolute',
top: 0,
@@ -202,7 +212,7 @@ const AppleTVHero: React.FC = ({
right: 0,
height: HERO_HEIGHT * 0.9, // 90% of hero height
overflow: 'hidden',
- opacity: trailerOpacity.value,
+ opacity: trailerOpacity.value * dragFade,
};
});
@@ -279,6 +289,27 @@ const AppleTVHero: React.FC = ({
setLogoHeights({});
}, [items.length]);
+ // Mark initial load as complete after a short delay
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setInitialLoadComplete(true);
+ }, 100);
+ return () => clearTimeout(timer);
+ }, []);
+
+ // Smooth fade-in when content loads
+ useEffect(() => {
+ if (currentItem && !loading) {
+ heroOpacity.value = withDelay(
+ 100,
+ withTiming(1, {
+ duration: 500,
+ easing: Easing.out(Easing.cubic),
+ })
+ );
+ }
+ }, [currentItem, loading, heroOpacity]);
+
// Stop trailer when screen loses focus
useEffect(() => {
if (!isFocused) {
@@ -501,6 +532,11 @@ const AppleTVHero: React.FC = ({
dragProgress.value = 0;
setNextIndex(currentIndex);
+ // Immediately hide trailer and show thumbnail when index changes
+ trailerOpacity.value = 0;
+ thumbnailOpacity.value = 1;
+ setTrailerPlaying(false);
+
// Faster logo fade
logoOpacity.value = 0;
logoOpacity.value = withDelay(
@@ -510,7 +546,7 @@ const AppleTVHero: React.FC = ({
easing: Easing.out(Easing.cubic),
})
);
- }, [currentIndex]);
+ }, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
// Callback for updating interaction time
const updateInteractionTime = useCallback(() => {
@@ -532,6 +568,11 @@ const AppleTVHero: React.FC = ({
setNextIndex(index);
}, []);
+ // Callback to hide trailer when drag starts
+ const hideTrailerOnDrag = useCallback(() => {
+ setTrailerPlaying(false);
+ }, [setTrailerPlaying]);
+
// Swipe gesture handler with live preview - only horizontal
const panGesture = useMemo(
() =>
@@ -541,6 +582,8 @@ const AppleTVHero: React.FC = ({
.onStart(() => {
// Determine which direction and set preview
runOnJS(updateInteractionTime)();
+ // Immediately stop trailer playback when drag starts
+ runOnJS(hideTrailerOnDrag)();
})
.onUpdate((event) => {
const translationX = event.translationX;
@@ -599,7 +642,7 @@ const AppleTVHero: React.FC = ({
});
}
}),
- [goToPrevious, goToNext, updateInteractionTime, setPreviewIndex, currentIndex, items.length]
+ [goToPrevious, goToNext, updateInteractionTime, setPreviewIndex, hideTrailerOnDrag, currentIndex, items.length]
);
// Animated styles for next image only - smooth crossfade + slide during drag
@@ -648,6 +691,13 @@ const AppleTVHero: React.FC = ({
};
});
+ // Animated style for hero container - smooth fade-in on load
+ const heroContainerStyle = useAnimatedStyle(() => {
+ return {
+ opacity: heroOpacity.value,
+ };
+ });
+
const handleDotPress = useCallback((index: number) => {
lastInteractionRef.current = Date.now();
setCurrentIndex(index);
@@ -691,7 +741,8 @@ const AppleTVHero: React.FC = ({
return (
{/* Background Images with Crossfade */}
diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx
index 1d8bd4c6..8761b431 100644
--- a/src/components/home/CatalogSection.tsx
+++ b/src/components/home/CatalogSection.tsx
@@ -176,6 +176,10 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
getItemType={getItemType}
horizontal
showsHorizontalScrollIndicator={false}
+ scrollEventThrottle={16}
+ decelerationRate="fast"
+ scrollEnabled={true}
+ nestedScrollEnabled={true}
contentContainerStyle={StyleSheet.flatten([
styles.catalogList,
{
@@ -186,7 +190,6 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7}
onEndReached={() => {}}
- // FlashList v2 optimizations
drawDistance={500}
/>
diff --git a/src/components/player/modals/LoadingOverlay.tsx b/src/components/player/modals/LoadingOverlay.tsx
index af49eaf8..0e5710c8 100644
--- a/src/components/player/modals/LoadingOverlay.tsx
+++ b/src/components/player/modals/LoadingOverlay.tsx
@@ -2,11 +2,10 @@ import React, { useEffect } from 'react';
import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
-import FastImage from '@d11/react-native-fast-image';
-import Reanimated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
+import Reanimated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
withRepeat,
withSequence,
Easing,
@@ -24,7 +23,6 @@ interface LoadingOverlayProps {
onClose: () => void;
width: number | string;
height: number | string;
- useFastImage?: boolean; // Platform-specific: iOS uses FastImage, Android uses Image
}
const LoadingOverlay: React.FC = ({
@@ -37,7 +35,6 @@ const LoadingOverlay: React.FC = ({
onClose,
width,
height,
- useFastImage = false,
}) => {
const logoOpacity = useSharedValue(0);
const logoScale = useSharedValue(1);
@@ -103,19 +100,11 @@ const LoadingOverlay: React.FC = ({
opacity: backdropImageOpacityAnim
}
]}>
- {useFastImage ? (
-
- ) : (
-
- )}
+
)}
= ({
},
logoAnimatedStyle
]}>
-
) : (
diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts
index 40122b24..694ddefa 100644
--- a/src/hooks/useMetadataAssets.ts
+++ b/src/hooks/useMetadataAssets.ts
@@ -65,113 +65,194 @@ export const useMetadataAssets = (
// For TMDB ID tracking
const [foundTmdbId, setFoundTmdbId] = useState(null);
+
+ const isMountedRef = useRef(true);
+
+ // CRITICAL: AbortController to cancel in-flight requests when component unmounts
+ const abortControllerRef = useRef(new AbortController());
+
+ // Track pending requests to prevent duplicate concurrent API calls
+ const pendingFetchRef = useRef | null>(null);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ // Cancel any in-flight requests
+ abortControllerRef.current.abort();
+ };
+ }, []);
+
+
+ useEffect(() => {
+ abortControllerRef.current = new AbortController();
+ }, [id, type]);
+
// Force reset when preference changes
useEffect(() => {
// Reset all cached data when preference changes
- setBannerImage(null);
- setBannerSource(null);
- forcedBannerRefreshDone.current = false;
+ if (isMountedRef.current) {
+ setBannerImage(null);
+ setBannerSource(null);
+ forcedBannerRefreshDone.current = false;
+ }
}, [settings.logoSourcePreference]);
- // Optimized banner fetching
+ // Optimized banner fetching with race condition fixes
const fetchBanner = useCallback(async () => {
- if (!metadata) return;
+ if (!metadata || !isMountedRef.current) return;
- setLoadingBanner(true);
-
-
- // If enrichment is disabled, use addon banner and don't fetch from external sources
- if (!settings.enrichMetadataWithTMDB) {
- const addonBanner = metadata?.banner || null;
- if (addonBanner && addonBanner !== bannerImage) {
- setBannerImage(addonBanner);
- setBannerSource('default');
+ // Prevent concurrent fetch requests for the same metadata
+ if (pendingFetchRef.current) {
+ try {
+ await pendingFetchRef.current;
+ } catch (error) {
+ // Previous request failed, allow new attempt
}
- setLoadingBanner(false);
- return;
}
- try {
- const currentPreference = settings.logoSourcePreference || 'tmdb';
- const preferredLanguage = settings.tmdbLanguagePreference || 'en';
- const contentType = type === 'series' ? 'tv' : 'movie';
-
- // Try to get a banner from the preferred source
- let finalBanner: string | null = null;
- let bannerSourceType: 'tmdb' | 'default' = 'default';
-
- // TMDB path only
- if (currentPreference === 'tmdb') {
- let tmdbId = null;
- if (id.startsWith('tmdb:')) {
- tmdbId = id.split(':')[1];
- } else if (foundTmdbId) {
- tmdbId = foundTmdbId;
- } else if ((metadata as any).tmdbId) {
- tmdbId = (metadata as any).tmdbId;
- } else if (imdbId && settings.enrichMetadataWithTMDB) {
- try {
- const tmdbService = TMDBService.getInstance();
- const foundId = await tmdbService.findTMDBIdByIMDB(imdbId);
- if (foundId) {
- tmdbId = String(foundId);
- }
- } catch (error) {
- // Handle error silently
- }
+ // Create a promise to track this fetch operation
+ const fetchPromise = (async () => {
+ try {
+ if (!isMountedRef.current) return;
+
+ if (isMountedRef.current) {
+ setLoadingBanner(true);
}
- if (tmdbId) {
- try {
- const tmdbService = TMDBService.getInstance();
- const endpoint = contentType === 'tv' ? 'tv' : 'movie';
-
- const details = endpoint === 'movie'
- ? await tmdbService.getMovieDetails(tmdbId)
- : await tmdbService.getTVShowDetails(Number(tmdbId));
-
- if (details?.backdrop_path) {
- finalBanner = tmdbService.getImageUrl(details.backdrop_path);
- bannerSourceType = 'tmdb';
-
- // Preload the image
- if (finalBanner) {
- FastImage.preload([{ uri: finalBanner }]);
+ // If enrichment is disabled, use addon banner and don't fetch from external sources
+ if (!settings.enrichMetadataWithTMDB) {
+ const addonBanner = metadata?.banner || null;
+ if (isMountedRef.current && addonBanner && addonBanner !== bannerImage) {
+ setBannerImage(addonBanner);
+ setBannerSource('default');
+ }
+ if (isMountedRef.current) {
+ setLoadingBanner(false);
+ }
+ return;
+ }
+
+ try {
+ const currentPreference = settings.logoSourcePreference || 'tmdb';
+ const contentType = type === 'series' ? 'tv' : 'movie';
+
+ // Collect final state before updating to prevent intermediate null states
+ let finalBanner: string | null = bannerImage; // Start with current to prevent flicker
+ let bannerSourceType: 'tmdb' | 'default' = (bannerSource === 'tmdb' || bannerSource === 'default') ? bannerSource : 'default';
+
+ // TMDB path only
+ if (currentPreference === 'tmdb') {
+ let tmdbId = null;
+ if (id.startsWith('tmdb:')) {
+ tmdbId = id.split(':')[1];
+ } else if (foundTmdbId) {
+ tmdbId = foundTmdbId;
+ } else if ((metadata as any).tmdbId) {
+ tmdbId = (metadata as any).tmdbId;
+ } else if (imdbId && settings.enrichMetadataWithTMDB) {
+ try {
+ const tmdbService = TMDBService.getInstance();
+ const foundId = await tmdbService.findTMDBIdByIMDB(imdbId);
+ if (foundId && isMountedRef.current) {
+ tmdbId = String(foundId);
+ }
+ } catch (error) {
+ // CRITICAL: Don't update state on error if unmounted
+ if (!isMountedRef.current) return;
+ logger.debug('[useMetadataAssets] TMDB ID lookup failed:', error);
}
}
- } catch (error) {
- // Handle error silently
+
+ if (tmdbId && isMountedRef.current) {
+ try {
+ const tmdbService = TMDBService.getInstance();
+ const endpoint = contentType === 'tv' ? 'tv' : 'movie';
+
+ // Fetch details (AbortSignal will be used for future implementations)
+ const details = endpoint === 'movie'
+ ? await tmdbService.getMovieDetails(tmdbId)
+ : await tmdbService.getTVShowDetails(Number(tmdbId));
+
+ // Only update if request wasn't aborted and component is still mounted
+ if (!isMountedRef.current) return;
+
+ if (details?.backdrop_path) {
+ finalBanner = tmdbService.getImageUrl(details.backdrop_path);
+ bannerSourceType = 'tmdb';
+
+ // Preload the image
+ if (finalBanner) {
+ FastImage.preload([{ uri: finalBanner }]);
+ }
+ } else {
+ // TMDB has no backdrop, gracefully fall back
+ finalBanner = metadata?.banner || bannerImage || null;
+ bannerSourceType = 'default';
+ }
+ } catch (error) {
+ // CRITICAL: Check if error is due to abort or actual network error
+ if (error instanceof Error && error.name === 'AbortError') {
+ // Request was cancelled, don't update state
+ return;
+ }
+
+ // Only update state if still mounted after error
+ if (!isMountedRef.current) return;
+
+ logger.debug('[useMetadataAssets] TMDB details fetch failed:', error);
+ // Keep current banner on error instead of setting to null
+ finalBanner = bannerImage || metadata?.banner || null;
+ bannerSourceType = 'default';
+ }
+ }
+ }
+
+ // Final fallback to metadata banner only
+ if (!finalBanner) {
+ finalBanner = metadata?.banner || null;
+ bannerSourceType = 'default';
+ }
+
+ // CRITICAL: Batch all state updates into a single call to prevent race conditions
+ // This ensures the native view hierarchy doesn't receive conflicting unmount/remount signals
+ if (isMountedRef.current && (finalBanner !== bannerImage || bannerSourceType !== bannerSource)) {
+ setBannerImage(finalBanner);
+ setBannerSource(bannerSourceType);
+ }
+
+ if (isMountedRef.current) {
+ forcedBannerRefreshDone.current = true;
+ }
+ } catch (error) {
+ // Outer catch for any unexpected errors
+ if (!isMountedRef.current) return;
+
+ logger.error('[useMetadataAssets] Unexpected error in banner fetch:', error);
+ // Use current banner on error, don't set to null
+ const defaultBanner = bannerImage || metadata?.banner || null;
+ if (defaultBanner !== bannerImage) {
+ setBannerImage(defaultBanner);
+ setBannerSource('default');
+ }
+ } finally {
+ if (isMountedRef.current) {
+ setLoadingBanner(false);
}
}
+ } finally {
+ pendingFetchRef.current = null;
}
-
- // Final fallback to metadata banner only
- if (!finalBanner) {
- finalBanner = metadata?.banner || null;
- bannerSourceType = 'default';
- }
-
- // Update state if the banner changed
- if (finalBanner !== bannerImage || bannerSourceType !== bannerSource) {
- setBannerImage(finalBanner);
- setBannerSource(bannerSourceType);
- }
-
- forcedBannerRefreshDone.current = true;
- } catch (error) {
- // Use default banner on error (only addon banner)
- const defaultBanner = metadata?.banner || null;
- if (defaultBanner !== bannerImage) {
- setBannerImage(defaultBanner);
- setBannerSource('default');
- }
- } finally {
- setLoadingBanner(false);
- }
+ })();
+
+ pendingFetchRef.current = fetchPromise;
+ return fetchPromise;
}, [metadata, id, type, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB, foundTmdbId, bannerImage, bannerSource]);
// Fetch banner when needed
useEffect(() => {
+ if (!isMountedRef.current) return;
+
const currentPreference = settings.logoSourcePreference || 'tmdb';
if (bannerSource !== currentPreference && !forcedBannerRefreshDone.current) {
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index b3bb9be7..fbb2c6e4 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -20,7 +20,7 @@ import {
InteractionManager,
AppState
} from 'react-native';
-import { LegendList } from '@legendapp/list';
+import { FlashList } from '@shopify/flash-list';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
@@ -841,18 +841,18 @@ const HomeScreen = () => {
backgroundColor="transparent"
translucent
/>
-
{/* Toasts are rendered globally at root */}