mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
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:
parent
d62874d20d
commit
81897b7242
4 changed files with 212 additions and 126 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue