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 */}