Refactor loading and image handling in Metadata components for smoother transitions

This update removes the fade animation from the MetadataLoadingScreen, allowing the parent component to manage transitions. In the HeroSection, enhancements have been made to the image loading state, introducing shimmer animations for a better user experience during loading. The MetadataScreen now features a skeleton loading screen with a fade-out transition, improving the overall content loading experience. Additionally, state management for image loading has been optimized to prevent unnecessary re-renders.
This commit is contained in:
tapframe 2025-06-18 10:47:27 +05:30
parent d62874d20d
commit 81897b7242
4 changed files with 212 additions and 126 deletions

View file

@ -22,19 +22,11 @@ export const MetadataLoadingScreen: React.FC<MetadataLoadingScreenProps> = ({
}) => {
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<MetadataLoadingScreenProps> = ({
barStyle="light-content"
/>
<Animated.View style={[
styles.content,
{ opacity: fadeAnim }
]}>
<View style={styles.content}>
{/* Hero Skeleton */}
<View style={styles.heroSection}>
<SkeletonElement
@ -230,7 +219,7 @@ export const MetadataLoadingScreen: React.FC<MetadataLoadingScreenProps> = ({
</View>
)}
</View>
</Animated.View>
</View>
</SafeAreaView>
);
};

View file

@ -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<HeroSectionProps> = ({
}) => {
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<HeroSectionProps> = ({
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<HeroSectionProps> = ({
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<HeroSectionProps> = ({
{/* Optimized Background */}
<View style={[styles.absoluteFill, { backgroundColor: currentTheme.colors.black }]} />
{/* Ultra-optimized Background Image */}
{/* Loading placeholder for smooth transition */}
{((imageSource && !imageLoaded) || loadingBanner) && (
<Animated.View style={[styles.absoluteFill, {
opacity: shimmerOpacity,
}]}>
<LinearGradient
colors={['#111', '#222', '#111']}
style={styles.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
)}
{/* Enhanced Background Image with smooth loading */}
{imageSource && !loadingBanner && (
<Animated.Image
source={{ uri: imageSource }}

View file

@ -225,9 +225,15 @@ export const useMetadataAssets = (
const fetchBanner = async () => {
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);

View file

@ -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 <MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} />;
}
return (
<Animated.View style={[StyleSheet.absoluteFill, transitionStyle]}>
<SafeAreaView
style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
edges={['bottom']}
>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
{/* Floating Header - Optimized */}
<FloatingHeader
metadata={metadata}
logoLoadError={assetData.logoLoadError}
handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary}
headerElementsY={animations.headerElementsY}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
setLogoLoadError={assetData.setLogoLoadError}
/>
<Animated.ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
bounces={false}
overScrollMode="never"
contentContainerStyle={styles.scrollContent}
<View style={StyleSheet.absoluteFill}>
{/* Skeleton Loading Screen - with fade out transition */}
{showSkeleton && (
<Animated.View
style={[StyleSheet.absoluteFill, skeletonStyle]}
pointerEvents={isContentReady ? 'none' : 'auto'}
>
{/* Hero Section - Optimized */}
<HeroSection
metadata={metadata}
bannerImage={assetData.bannerImage}
loadingBanner={assetData.loadingBanner}
logoLoadError={assetData.logoLoadError}
scrollY={animations.scrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
logoOpacity={animations.logoOpacity}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgressData.watchProgress}
type={type as 'movie' | 'series'}
getEpisodeDetails={watchProgressData.getEpisodeDetails}
handleShowStreams={handleShowStreams}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
id={id}
navigation={navigation}
getPlayButtonText={watchProgressData.getPlayButtonText}
setBannerImage={assetData.setBannerImage}
setLogoLoadError={assetData.setLogoLoadError}
/>
<MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} />
</Animated.View>
)}
{/* Main Content - Optimized */}
<Animated.View style={contentStyle}>
<MetadataDetails
{/* Main Content - with fade in transition */}
{metadata && (
<Animated.View
style={[StyleSheet.absoluteFill, transitionStyle]}
pointerEvents={isContentReady ? 'auto' : 'none'}
>
<SafeAreaView
style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
edges={['bottom']}
>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
{/* Floating Header - Optimized */}
<FloatingHeader
metadata={metadata}
imdbId={imdbId}
type={type as 'movie' | 'series'}
renderRatings={() => imdbId ? (
<RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} />
) : null}
logoLoadError={assetData.logoLoadError}
handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary}
headerElementsY={animations.headerElementsY}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
setLogoLoadError={assetData.setLogoLoadError}
/>
<CastSection
cast={cast}
loadingCast={loadingCast}
onSelectCastMember={handleSelectCastMember}
/>
{type === 'movie' && (
<MoreLikeThisSection
recommendations={recommendations}
loadingRecommendations={loadingRecommendations}
<Animated.ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
bounces={false}
overScrollMode="never"
contentContainerStyle={styles.scrollContent}
>
{/* Hero Section - Optimized */}
<HeroSection
metadata={metadata}
bannerImage={assetData.bannerImage}
loadingBanner={assetData.loadingBanner}
logoLoadError={assetData.logoLoadError}
scrollY={animations.scrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
logoOpacity={animations.logoOpacity}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgressData.watchProgress}
type={type as 'movie' | 'series'}
getEpisodeDetails={watchProgressData.getEpisodeDetails}
handleShowStreams={handleShowStreams}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
id={id}
navigation={navigation}
getPlayButtonText={watchProgressData.getPlayButtonText}
setBannerImage={assetData.setBannerImage}
setLogoLoadError={assetData.setLogoLoadError}
/>
)}
{type === 'series' ? (
<SeriesContent
episodes={episodes}
selectedSeason={selectedSeason}
loadingSeasons={loadingSeasons}
onSeasonChange={handleSeasonChangeWithHaptics}
onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes}
metadata={metadata || undefined}
/>
) : (
metadata && <MovieContent metadata={metadata} />
)}
</Animated.View>
</Animated.ScrollView>
</SafeAreaView>
</Animated.View>
{/* Main Content - Optimized */}
<Animated.View style={contentStyle}>
<MetadataDetails
metadata={metadata}
imdbId={imdbId}
type={type as 'movie' | 'series'}
renderRatings={() => imdbId ? (
<RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} />
) : null}
/>
<CastSection
cast={cast}
loadingCast={loadingCast}
onSelectCastMember={handleSelectCastMember}
/>
{type === 'movie' && (
<MoreLikeThisSection
recommendations={recommendations}
loadingRecommendations={loadingRecommendations}
/>
)}
{type === 'series' ? (
<SeriesContent
episodes={episodes}
selectedSeason={selectedSeason}
loadingSeasons={loadingSeasons}
onSeasonChange={handleSeasonChangeWithHaptics}
onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes}
metadata={metadata || undefined}
/>
) : (
metadata && <MovieContent metadata={metadata} />
)}
</Animated.View>
</Animated.ScrollView>
</SafeAreaView>
</Animated.View>
)}
</View>
);
};