diff --git a/src/components/loading/MetadataLoadingScreen.tsx b/src/components/loading/MetadataLoadingScreen.tsx index 39388bb8..c0a8ebf5 100644 --- a/src/components/loading/MetadataLoadingScreen.tsx +++ b/src/components/loading/MetadataLoadingScreen.tsx @@ -22,19 +22,11 @@ export const MetadataLoadingScreen: React.FC = ({ }) => { const { currentTheme } = useTheme(); - // Animation values - const fadeAnim = useRef(new Animated.Value(0)).current; + // Animation values - removed fadeAnim since parent handles transitions const pulseAnim = useRef(new Animated.Value(0.3)).current; const shimmerAnim = useRef(new Animated.Value(0)).current; useEffect(() => { - // Start entrance animation - Animated.timing(fadeAnim, { - toValue: 1, - duration: 800, - useNativeDriver: true, - }).start(); - // Continuous pulse animation for skeleton elements const pulseAnimation = Animated.loop( Animated.sequence([ @@ -138,10 +130,7 @@ export const MetadataLoadingScreen: React.FC = ({ barStyle="light-content" /> - + {/* Hero Skeleton */} = ({ )} - + ); }; diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 9c17a755..25756188 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -16,6 +16,7 @@ import Animated, { useSharedValue, withTiming, runOnJS, + withRepeat, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; import { logger } from '../../utils/logger'; @@ -246,19 +247,47 @@ const HeroSection: React.FC = ({ }) => { const { currentTheme } = useTheme(); - // Minimal state for image handling + // Enhanced state for smooth image loading const [imageError, setImageError] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); const imageOpacity = useSharedValue(1); + const imageLoadOpacity = useSharedValue(0); + const shimmerOpacity = useSharedValue(0.3); // Memoized image source const imageSource = useMemo(() => bannerImage || metadata.banner || metadata.poster , [bannerImage, metadata.banner, metadata.poster]); - // Ultra-fast image handlers + // Start shimmer animation for loading state + useEffect(() => { + if (!imageLoaded && imageSource) { + // Start shimmer animation + shimmerOpacity.value = withRepeat( + withTiming(0.8, { duration: 1200 }), + -1, + true + ); + } else { + // Stop shimmer when loaded + shimmerOpacity.value = withTiming(0.3, { duration: 300 }); + } + }, [imageLoaded, imageSource]); + + // Reset loading state when image source changes + useEffect(() => { + if (imageSource) { + setImageLoaded(false); + imageLoadOpacity.value = 0; + } + }, [imageSource]); + + // Enhanced image handlers with smooth transitions const handleImageError = () => { setImageError(true); + setImageLoaded(false); imageOpacity.value = withTiming(0.6, { duration: 150 }); + imageLoadOpacity.value = withTiming(0, { duration: 150 }); runOnJS(() => { if (bannerImage !== metadata.banner) { setBannerImage(metadata.banner || metadata.poster); @@ -268,7 +297,10 @@ const HeroSection: React.FC = ({ const handleImageLoad = () => { setImageError(false); + setImageLoaded(true); imageOpacity.value = withTiming(1, { duration: 150 }); + // Smooth fade-in for the loaded image + imageLoadOpacity.value = withTiming(1, { duration: 400 }); }; // Ultra-optimized animated styles - single calculations @@ -293,14 +325,14 @@ const HeroSection: React.FC = ({ opacity: watchProgressOpacity.value, }), []); - // Ultra-optimized backdrop with minimal calculations + // Enhanced backdrop with smooth loading animation const backdropImageStyle = useAnimatedStyle(() => { 'worklet'; const translateY = scrollY.value * PARALLAX_FACTOR; const scale = 1 + (scrollY.value * 0.0001); // Micro scale effect return { - opacity: imageOpacity.value, + opacity: imageOpacity.value * imageLoadOpacity.value, transform: [ { translateY: -Math.min(translateY, 100) }, // Cap translation { scale: Math.min(scale, SCALE_FACTOR) } // Cap scale @@ -346,7 +378,21 @@ const HeroSection: React.FC = ({ {/* Optimized Background */} - {/* Ultra-optimized Background Image */} + {/* Loading placeholder for smooth transition */} + {((imageSource && !imageLoaded) || loadingBanner) && ( + + + + )} + + {/* Enhanced Background Image with smooth loading */} {imageSource && !loadingBanner && ( { logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`); - setLoadingBanner(true); - setBannerImage(null); // Clear existing banner to prevent mixed sources - setBannerSource(null); // Clear source tracking + setLoadingBanner(true); + + // Show fallback banner immediately to prevent blank state + const fallbackBanner = metadata?.banner || metadata?.poster || null; + if (fallbackBanner && !bannerImage) { + setBannerImage(fallbackBanner); + setBannerSource('default'); + logger.log(`[useMetadataAssets:Banner] Setting immediate fallback banner: ${fallbackBanner}`); + } let finalBanner: string | null = null; let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default'; @@ -419,17 +425,31 @@ export const useMetadataAssets = ( // Set the final state logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`); - setBannerImage(finalBanner); - setBannerSource(bannerSourceType); // Track the source of the final image + + // Only update if the banner actually changed to avoid unnecessary re-renders + if (finalBanner !== bannerImage || bannerSourceType !== bannerSource) { + setBannerImage(finalBanner); + setBannerSource(bannerSourceType); // Track the source of the final image + logger.log(`[useMetadataAssets:Banner] Banner updated from ${bannerImage} to ${finalBanner}`); + } else { + logger.log(`[useMetadataAssets:Banner] Banner unchanged, skipping update`); + } + forcedBannerRefreshDone.current = true; // Mark this cycle as complete } catch (error) { logger.error(`[useMetadataAssets:Banner] Error in outer fetchBanner try block:`, error); // Ensure fallback to default even on outer error const defaultBanner = metadata?.banner || metadata?.poster || null; - setBannerImage(defaultBanner); - setBannerSource('default'); - logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`); + + // Only set if it's different from current banner + if (defaultBanner !== bannerImage) { + setBannerImage(defaultBanner); + setBannerSource('default'); + logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`); + } else { + logger.log(`[useMetadataAssets:Banner] Default banner already set, skipping update`); + } } finally { logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`); setLoadingBanner(false); diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 29a690ba..d6caeb69 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -55,7 +55,9 @@ const MetadataScreen: React.FC = () => { // Optimized state management - reduced state variables const [isContentReady, setIsContentReady] = useState(false); + const [showSkeleton, setShowSkeleton] = useState(true); const transitionOpacity = useSharedValue(0); + const skeletonOpacity = useSharedValue(1); const { metadata, @@ -85,14 +87,26 @@ const MetadataScreen: React.FC = () => { // Memoized derived values for performance const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]); - // Ultra-fast content transition + // Smooth skeleton to content transition useEffect(() => { if (isReady && !isContentReady) { - setIsContentReady(true); - transitionOpacity.value = withTiming(1, { duration: 200 }); + // Small delay to ensure skeleton is rendered before starting transition + setTimeout(() => { + // Start fade out skeleton and fade in content simultaneously + skeletonOpacity.value = withTiming(0, { duration: 300 }); + transitionOpacity.value = withTiming(1, { duration: 400 }); + + // Hide skeleton after fade out completes + setTimeout(() => { + setShowSkeleton(false); + setIsContentReady(true); + }, 300); + }, 100); } else if (!isReady && isContentReady) { setIsContentReady(false); + setShowSkeleton(true); transitionOpacity.value = 0; + skeletonOpacity.value = 1; } }, [isReady, isContentReady]); @@ -143,6 +157,10 @@ const MetadataScreen: React.FC = () => { opacity: transitionOpacity.value, }), []); + const skeletonStyle = useAnimatedStyle(() => ({ + opacity: skeletonOpacity.value, + }), []); + // Memoized error component for performance const ErrorComponent = useMemo(() => { if (!metadataError) return null; @@ -181,110 +199,123 @@ const MetadataScreen: React.FC = () => { return ErrorComponent; } - // Show loading screen - if (loading || !isContentReady) { - return ; - } - return ( - - - - - {/* Floating Header - Optimized */} - - - + {/* Skeleton Loading Screen - with fade out transition */} + {showSkeleton && ( + - {/* Hero Section - Optimized */} - + + + )} - {/* Main Content - Optimized */} - - + + + + {/* Floating Header - Optimized */} + imdbId ? ( - - ) : null} + logoLoadError={assetData.logoLoadError} + handleBack={handleBack} + handleToggleLibrary={handleToggleLibrary} + headerElementsY={animations.headerElementsY} + inLibrary={inLibrary} + headerOpacity={animations.headerOpacity} + headerElementsOpacity={animations.headerElementsOpacity} + safeAreaTop={safeAreaTop} + setLogoLoadError={assetData.setLogoLoadError} /> - - - {type === 'movie' && ( - + {/* Hero Section - Optimized */} + - )} - {type === 'series' ? ( - - ) : ( - metadata && - )} - - - - + {/* Main Content - Optimized */} + + imdbId ? ( + + ) : null} + /> + + + + {type === 'movie' && ( + + )} + + {type === 'series' ? ( + + ) : ( + metadata && + )} + + + + + )} + ); };