apple hero changes

This commit is contained in:
tapframe 2025-11-10 15:23:55 +05:30
parent c9c4a80387
commit 1ca4e275de
10 changed files with 356 additions and 225 deletions

View file

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

View file

@ -1,101 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.8</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>23</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.8</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>23</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View file

@ -1,5 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array/>
</dict>
</plist>

8
package-lock.json generated
View file

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

View file

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

View file

@ -164,6 +164,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const [logoHeights, setLogoHeights] = useState<Record<number, number>>({});
const autoPlayTimerRef = useRef<NodeJS.Timeout | null>(null);
const lastInteractionRef = useRef<number>(Date.now());
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
// Trailer state
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
@ -192,9 +193,18 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
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<AppleTVHeroProps> = ({
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<AppleTVHeroProps> = ({
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<AppleTVHeroProps> = ({
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<AppleTVHeroProps> = ({
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<AppleTVHeroProps> = ({
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<AppleTVHeroProps> = ({
.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<AppleTVHeroProps> = ({
});
}
}),
[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<AppleTVHeroProps> = ({
};
});
// 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<AppleTVHeroProps> = ({
return (
<GestureDetector gesture={panGesture}>
<Animated.View
style={[styles.container, { height: HERO_HEIGHT, marginTop: -insets.top }]}
entering={initialLoadComplete ? undefined : FadeIn.duration(600).delay(150)}
style={[styles.container, heroContainerStyle, { height: HERO_HEIGHT, marginTop: -insets.top }]}
>
{/* Background Images with Crossfade */}
<View style={styles.backgroundContainer}>

View file

@ -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}
/>
</Animated.View>

View file

@ -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<LoadingOverlayProps> = ({
@ -37,7 +35,6 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
onClose,
width,
height,
useFastImage = false,
}) => {
const logoOpacity = useSharedValue(0);
const logoScale = useSharedValue(1);
@ -103,19 +100,11 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
opacity: backdropImageOpacityAnim
}
]}>
{useFastImage ? (
<FastImage
source={{ uri: backdrop }}
style={StyleSheet.absoluteFillObject}
resizeMode={FastImage.resizeMode.cover}
/>
) : (
<Image
source={{ uri: backdrop }}
style={StyleSheet.absoluteFillObject}
resizeMode="cover"
/>
)}
<Image
source={{ uri: backdrop }}
style={StyleSheet.absoluteFillObject}
resizeMode="cover"
/>
</Animated.View>
)}
<LinearGradient
@ -145,13 +134,13 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
},
logoAnimatedStyle
]}>
<FastImage
<Image
source={{ uri: logo }}
style={{
width: 300,
height: 180,
}}
resizeMode={FastImage.resizeMode.contain}
resizeMode="contain"
/>
</Reanimated.View>
) : (

View file

@ -65,113 +65,194 @@ export const useMetadataAssets = (
// For TMDB ID tracking
const [foundTmdbId, setFoundTmdbId] = useState<string | null>(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<Promise<void> | 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) {

View file

@ -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
/>
<LegendList
<FlashList
data={listData}
renderItem={renderListItem}
keyExtractor={keyExtractor}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
nestedScrollEnabled={true}
ListHeaderComponent={memoizedHeader}
ListFooterComponent={ListFooterComponent}
onEndReached={handleLoadMoreCatalogs}
onEndReachedThreshold={0.6}
recycleItems={true}
maintainVisibleContentPosition={Platform.OS !== 'ios'} // Disable on iOS to prevent shifting
onScroll={handleScroll}
/>
{/* Toasts are rendered globally at root */}