From 10cbf077d656751a99ab81c2d6a99e59660ef98a Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 22:25:42 +0530 Subject: [PATCH] Refactor MetadataScreen to enhance modularity and integrate new components This update significantly refactors the MetadataScreen component by removing unused imports and consolidating logic into dedicated hooks and components. The new structure improves readability and maintainability, while also enhancing the user experience with a more cohesive layout. Key changes include the introduction of HeroSection, FloatingHeader, and MetadataDetails components, which streamline the rendering process and improve the overall UI consistency. Additionally, the integration of new hooks for managing metadata assets and animations optimizes performance and responsiveness. --- src/components/metadata/FloatingHeader.tsx | 235 +++ src/components/metadata/HeroSection.tsx | 519 +++++ src/components/metadata/MetadataDetails.tsx | 180 ++ src/hooks/useMetadataAnimations.ts | 247 +++ src/hooks/useMetadataAssets.ts | 510 +++++ src/hooks/useWatchProgress.ts | 216 +++ src/screens/MetadataScreen.tsx | 1879 +------------------ 7 files changed, 1992 insertions(+), 1794 deletions(-) create mode 100644 src/components/metadata/FloatingHeader.tsx create mode 100644 src/components/metadata/HeroSection.tsx create mode 100644 src/components/metadata/MetadataDetails.tsx create mode 100644 src/hooks/useMetadataAnimations.ts create mode 100644 src/hooks/useMetadataAssets.ts create mode 100644 src/hooks/useWatchProgress.ts diff --git a/src/components/metadata/FloatingHeader.tsx b/src/components/metadata/FloatingHeader.tsx new file mode 100644 index 00000000..619723f3 --- /dev/null +++ b/src/components/metadata/FloatingHeader.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Platform, + Dimensions, +} from 'react-native'; +import { BlurView as ExpoBlurView } from 'expo-blur'; +import { BlurView as CommunityBlurView } from '@react-native-community/blur'; +import { MaterialIcons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import Animated, { + useAnimatedStyle, + interpolate, + Extrapolate, +} from 'react-native-reanimated'; +import { colors } from '../../styles/colors'; +import { logger } from '../../utils/logger'; + +const { width } = Dimensions.get('window'); + +interface FloatingHeaderProps { + metadata: any; + logoLoadError: boolean; + handleBack: () => void; + handleToggleLibrary: () => void; + inLibrary: boolean; + headerOpacity: Animated.SharedValue; + headerElementsY: Animated.SharedValue; + headerElementsOpacity: Animated.SharedValue; + safeAreaTop: number; + setLogoLoadError: (error: boolean) => void; +} + +const FloatingHeader: React.FC = ({ + metadata, + logoLoadError, + handleBack, + handleToggleLibrary, + inLibrary, + headerOpacity, + headerElementsY, + headerElementsOpacity, + safeAreaTop, + setLogoLoadError, +}) => { + // Animated styles for the header + const headerAnimatedStyle = useAnimatedStyle(() => ({ + opacity: headerOpacity.value, + transform: [ + { translateY: interpolate(headerOpacity.value, [0, 1], [-20, 0], Extrapolate.CLAMP) } + ] + })); + + // Animated style for header elements + const headerElementsStyle = useAnimatedStyle(() => ({ + opacity: headerElementsOpacity.value, + transform: [{ translateY: headerElementsY.value }] + })); + + return ( + + {Platform.OS === 'ios' ? ( + + + + + + + + {metadata.logo && !logoLoadError ? ( + { + logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} + /> + ) : ( + {metadata.name} + )} + + + + + + + + ) : ( + + + + + + + + + {metadata.logo && !logoLoadError ? ( + { + logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} + /> + ) : ( + {metadata.name} + )} + + + + + + + + )} + {Platform.OS === 'ios' && } + + ); +}; + +const styles = StyleSheet.create({ + floatingHeader: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + overflow: 'hidden', + elevation: 4, // for Android shadow + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 3, + }, + blurContainer: { + width: '100%', + }, + floatingHeaderContent: { + height: 56, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + }, + headerBottomBorder: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 0.5, + backgroundColor: 'rgba(255,255,255,0.15)', + }, + headerTitleContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 10, + }, + backButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 20, + }, + headerActionButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 20, + }, + floatingHeaderLogo: { + height: 42, + width: width * 0.6, + maxWidth: 240, + }, + floatingHeaderTitle: { + color: colors.highEmphasis, + fontSize: 18, + fontWeight: '700', + textAlign: 'center', + }, + absoluteFill: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +}); + +export default React.memo(FloatingHeader); \ No newline at end of file diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx new file mode 100644 index 00000000..74c641b3 --- /dev/null +++ b/src/components/metadata/HeroSection.tsx @@ -0,0 +1,519 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + Dimensions, + TouchableOpacity, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Image } from 'expo-image'; +import Animated, { + useAnimatedStyle, + interpolate, + Extrapolate, +} from 'react-native-reanimated'; +import { colors } from '../../styles/colors'; +import { logger } from '../../utils/logger'; + +const { width, height } = Dimensions.get('window'); + +// Types +interface HeroSectionProps { + metadata: any; + bannerImage: string | null; + loadingBanner: boolean; + logoLoadError: boolean; + scrollY: Animated.SharedValue; + dampedScrollY: Animated.SharedValue; + heroHeight: Animated.SharedValue; + heroOpacity: Animated.SharedValue; + heroScale: Animated.SharedValue; + logoOpacity: Animated.SharedValue; + logoScale: Animated.SharedValue; + genresOpacity: Animated.SharedValue; + genresTranslateY: Animated.SharedValue; + buttonsOpacity: Animated.SharedValue; + buttonsTranslateY: Animated.SharedValue; + watchProgressOpacity: Animated.SharedValue; + watchProgressScaleY: Animated.SharedValue; + watchProgress: { + currentTime: number; + duration: number; + lastUpdated: number; + episodeId?: string; + } | null; + type: 'movie' | 'series'; + getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; + handleShowStreams: () => void; + handleToggleLibrary: () => void; + inLibrary: boolean; + id: string; + navigation: any; + getPlayButtonText: () => string; + setBannerImage: (bannerImage: string | null) => void; + setLogoLoadError: (error: boolean) => void; +} + +// Memoized ActionButtons Component +const ActionButtons = React.memo(({ + handleShowStreams, + toggleLibrary, + inLibrary, + type, + id, + navigation, + playButtonText, + animatedStyle +}: { + handleShowStreams: () => void; + toggleLibrary: () => void; + inLibrary: boolean; + type: 'movie' | 'series'; + id: string; + navigation: any; + playButtonText: string; + animatedStyle: any; +}) => { + return ( + + + + + {playButtonText} + + + + + + + {inLibrary ? 'Saved' : 'Save'} + + + + {type === 'series' && ( + { + navigation.navigate('ShowRatings', { showId: id.split(':')[1] }); + }} + > + + + )} + + ); +}); + +// Memoized WatchProgress Component +const WatchProgressDisplay = React.memo(({ + watchProgress, + type, + getEpisodeDetails, + animatedStyle +}: { + watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null; + type: 'movie' | 'series'; + getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; + animatedStyle: any; +}) => { + if (!watchProgress || watchProgress.duration === 0) { + return null; + } + + const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString(); + let episodeInfo = ''; + + if (type === 'series' && watchProgress.episodeId) { + const details = getEpisodeDetails(watchProgress.episodeId); + if (details) { + episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; + } + } + + return ( + + + + + + {progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime} + + + ); +}); + +const HeroSection: React.FC = ({ + metadata, + bannerImage, + loadingBanner, + logoLoadError, + scrollY, + dampedScrollY, + heroHeight, + heroOpacity, + heroScale, + logoOpacity, + logoScale, + genresOpacity, + genresTranslateY, + buttonsOpacity, + buttonsTranslateY, + watchProgressOpacity, + watchProgressScaleY, + watchProgress, + type, + getEpisodeDetails, + handleShowStreams, + handleToggleLibrary, + inLibrary, + id, + navigation, + getPlayButtonText, + setBannerImage, + setLogoLoadError, +}) => { + // Animated styles + const heroAnimatedStyle = useAnimatedStyle(() => ({ + width: '100%', + height: heroHeight.value, + backgroundColor: colors.black, + transform: [{ scale: heroScale.value }], + opacity: heroOpacity.value, + })); + + const logoAnimatedStyle = useAnimatedStyle(() => ({ + opacity: logoOpacity.value, + transform: [{ scale: logoScale.value }] + })); + + const watchProgressAnimatedStyle = useAnimatedStyle(() => ({ + opacity: watchProgressOpacity.value, + transform: [ + { + translateY: interpolate( + watchProgressScaleY.value, + [0, 1], + [-8, 0], + Extrapolate.CLAMP + ) + }, + { scaleY: watchProgressScaleY.value } + ] + })); + + const genresAnimatedStyle = useAnimatedStyle(() => ({ + opacity: genresOpacity.value, + transform: [{ translateY: genresTranslateY.value }] + })); + + const buttonsAnimatedStyle = useAnimatedStyle(() => ({ + opacity: buttonsOpacity.value, + transform: [{ translateY: buttonsTranslateY.value }] + })); + + const parallaxImageStyle = useAnimatedStyle(() => ({ + width: '100%', + height: '120%', + top: '-10%', + transform: [ + { + translateY: interpolate( + dampedScrollY.value, + [0, 100, 300], + [20, -20, -60], + Extrapolate.CLAMP + ) + }, + { + scale: interpolate( + dampedScrollY.value, + [0, 150, 300], + [1.1, 1.02, 0.95], + Extrapolate.CLAMP + ) + } + ], + })); + + // Render genres + const renderGenres = () => { + if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) { + return null; + } + + const genresToDisplay: string[] = metadata.genres as string[]; + + return genresToDisplay.slice(0, 4).map((genreName, index, array) => ( + + {genreName} + {index < array.length - 1 && ( + + )} + + )); + }; + + return ( + + + {loadingBanner ? ( + + ) : ( + { + logger.warn(`[HeroSection] Banner failed to load: ${bannerImage}`); + if (bannerImage !== metadata.banner) { + setBannerImage(metadata.banner || metadata.poster); + } + }} + /> + )} + + + {/* Title/Logo */} + + + {metadata.logo && !logoLoadError ? ( + { + logger.warn(`[HeroSection] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} + /> + ) : ( + {metadata.name} + )} + + + + {/* Watch Progress */} + + + {/* Genre Tags */} + + + {renderGenres()} + + + + {/* Action Buttons */} + + + + + + ); +}; + +const styles = StyleSheet.create({ + heroSection: { + width: '100%', + height: height * 0.5, + backgroundColor: colors.black, + overflow: 'hidden', + }, + absoluteFill: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + heroGradient: { + flex: 1, + justifyContent: 'flex-end', + paddingBottom: 24, + }, + heroContent: { + padding: 16, + paddingTop: 12, + paddingBottom: 12, + }, + logoContainer: { + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + titleLogoContainer: { + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + titleLogo: { + width: width * 0.8, + height: 100, + marginBottom: 0, + alignSelf: 'center', + }, + heroTitle: { + color: colors.highEmphasis, + fontSize: 28, + fontWeight: '900', + marginBottom: 12, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 2 }, + textShadowRadius: 4, + letterSpacing: -0.5, + }, + genreContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + alignItems: 'center', + marginTop: 8, + marginBottom: 16, + gap: 4, + }, + genreText: { + color: colors.text, + fontSize: 12, + fontWeight: '500', + }, + genreDot: { + color: colors.text, + fontSize: 12, + fontWeight: '500', + opacity: 0.6, + marginHorizontal: 4, + }, + actionButtons: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + marginBottom: -12, + justifyContent: 'center', + width: '100%', + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + borderRadius: 100, + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + flex: 1, + }, + playButton: { + backgroundColor: colors.white, + }, + infoButton: { + backgroundColor: 'rgba(255,255,255,0.2)', + borderWidth: 2, + borderColor: '#fff', + }, + iconButton: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(255,255,255,0.2)', + borderWidth: 2, + borderColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + }, + playButtonText: { + color: '#000', + fontWeight: '600', + marginLeft: 8, + fontSize: 16, + }, + infoButtonText: { + color: '#fff', + marginLeft: 8, + fontWeight: '600', + fontSize: 16, + }, + watchProgressContainer: { + marginTop: 6, + marginBottom: 8, + width: '100%', + alignItems: 'center', + overflow: 'hidden', + height: 48, + }, + watchProgressBar: { + width: '75%', + height: 3, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + borderRadius: 1.5, + overflow: 'hidden', + marginBottom: 6 + }, + watchProgressFill: { + height: '100%', + backgroundColor: colors.primary, + borderRadius: 1.5, + }, + watchProgressText: { + color: colors.textMuted, + fontSize: 12, + textAlign: 'center', + opacity: 0.9, + letterSpacing: 0.2 + }, +}); + +export default React.memo(HeroSection); \ No newline at end of file diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx new file mode 100644 index 00000000..a32b67b6 --- /dev/null +++ b/src/components/metadata/MetadataDetails.tsx @@ -0,0 +1,180 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import Animated, { + Layout, + Easing, + FadeIn, +} from 'react-native-reanimated'; +import { colors } from '../../styles/colors'; + +interface MetadataDetailsProps { + metadata: any; + imdbId: string | null; + type: 'movie' | 'series'; +} + +const MetadataDetails: React.FC = ({ + metadata, + imdbId, + type, +}) => { + const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); + + return ( + <> + {/* Meta Info */} + + {metadata.year && ( + {metadata.year} + )} + {metadata.runtime && ( + {metadata.runtime} + )} + {metadata.certification && ( + {metadata.certification} + )} + {metadata.imdbRating && ( + + + {metadata.imdbRating} + + )} + + + {/* Creator/Director Info */} + + {metadata.directors && metadata.directors.length > 0 && ( + + Director{metadata.directors.length > 1 ? 's' : ''}: + {metadata.directors.join(', ')} + + )} + {metadata.creators && metadata.creators.length > 0 && ( + + Creator{metadata.creators.length > 1 ? 's' : ''}: + {metadata.creators.join(', ')} + + )} + + + {/* Description */} + {metadata.description && ( + + setIsFullDescriptionOpen(!isFullDescriptionOpen)} + activeOpacity={0.7} + > + + {metadata.description} + + + + {isFullDescriptionOpen ? 'Show Less' : 'Show More'} + + + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + metaInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 16, + marginBottom: 12, + }, + metaText: { + color: colors.text, + fontSize: 15, + fontWeight: '700', + letterSpacing: 0.3, + textTransform: 'uppercase', + opacity: 0.9, + }, + ratingContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + imdbLogo: { + width: 35, + height: 18, + marginRight: 4, + }, + ratingText: { + color: colors.text, + fontWeight: '700', + fontSize: 15, + letterSpacing: 0.3, + }, + creatorContainer: { + marginBottom: 2, + paddingHorizontal: 16, + }, + creatorSection: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + height: 20 + }, + creatorLabel: { + color: colors.white, + fontSize: 14, + fontWeight: '600', + marginRight: 8, + lineHeight: 20 + }, + creatorText: { + color: colors.lightGray, + fontSize: 14, + flex: 1, + lineHeight: 20 + }, + descriptionContainer: { + marginBottom: 16, + paddingHorizontal: 16, + }, + description: { + color: colors.mediumEmphasis, + fontSize: 15, + lineHeight: 24, + }, + showMoreButton: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 8, + paddingVertical: 4, + }, + showMoreText: { + color: colors.textMuted, + fontSize: 14, + marginRight: 4, + }, +}); + +export default React.memo(MetadataDetails); \ No newline at end of file diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts new file mode 100644 index 00000000..7ef53e62 --- /dev/null +++ b/src/hooks/useMetadataAnimations.ts @@ -0,0 +1,247 @@ +import { useEffect } from 'react'; +import { Dimensions } from 'react-native'; +import { + useSharedValue, + withTiming, + withSpring, + Easing, + useAnimatedScrollHandler, + interpolate, + Extrapolate, +} from 'react-native-reanimated'; + +const { width, height } = Dimensions.get('window'); + +// Animation constants +const springConfig = { + damping: 20, + mass: 1, + stiffness: 100 +}; + +// Animation timing constants for staggered appearance +const ANIMATION_DELAY_CONSTANTS = { + HERO: 100, + LOGO: 250, + PROGRESS: 350, + GENRES: 400, + BUTTONS: 450, + CONTENT: 500 +}; + +export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => { + // Animation values for screen entrance + const screenScale = useSharedValue(0.92); + const screenOpacity = useSharedValue(0); + + // Animation values for hero section + const heroHeight = useSharedValue(height * 0.5); + const heroScale = useSharedValue(1.05); + const heroOpacity = useSharedValue(0); + + // Animation values for content + const contentTranslateY = useSharedValue(60); + + // Animation values for logo + const logoOpacity = useSharedValue(0); + const logoScale = useSharedValue(0.9); + + // Animation values for progress + const watchProgressOpacity = useSharedValue(0); + const watchProgressScaleY = useSharedValue(0); + + // Animation values for genres + const genresOpacity = useSharedValue(0); + const genresTranslateY = useSharedValue(20); + + // Animation values for buttons + const buttonsOpacity = useSharedValue(0); + const buttonsTranslateY = useSharedValue(30); + + // Scroll values for parallax effect + const scrollY = useSharedValue(0); + const dampedScrollY = useSharedValue(0); + + // Header animation values + const headerOpacity = useSharedValue(0); + const headerElementsY = useSharedValue(-10); + const headerElementsOpacity = useSharedValue(0); + + // Start entrance animation + useEffect(() => { + // Use a timeout to ensure the animations starts after the component is mounted + const animationTimeout = setTimeout(() => { + // 1. First animate the container + screenScale.value = withSpring(1, springConfig); + screenOpacity.value = withSpring(1, springConfig); + + // 2. Then animate the hero section with a slight delay + setTimeout(() => { + heroOpacity.value = withSpring(1, { + damping: 14, + stiffness: 80 + }); + heroScale.value = withSpring(1, { + damping: 18, + stiffness: 100 + }); + }, ANIMATION_DELAY_CONSTANTS.HERO); + + // 3. Then animate the logo + setTimeout(() => { + logoOpacity.value = withSpring(1, { + damping: 12, + stiffness: 100 + }); + logoScale.value = withSpring(1, { + damping: 14, + stiffness: 90 + }); + }, ANIMATION_DELAY_CONSTANTS.LOGO); + + // 4. Then animate the watch progress if applicable + setTimeout(() => { + if (watchProgress && watchProgress.duration > 0) { + watchProgressOpacity.value = withSpring(1, { + damping: 14, + stiffness: 100 + }); + watchProgressScaleY.value = withSpring(1, { + damping: 18, + stiffness: 120 + }); + } + }, ANIMATION_DELAY_CONSTANTS.PROGRESS); + + // 5. Then animate the genres + setTimeout(() => { + genresOpacity.value = withSpring(1, { + damping: 14, + stiffness: 100 + }); + genresTranslateY.value = withSpring(0, { + damping: 18, + stiffness: 120 + }); + }, ANIMATION_DELAY_CONSTANTS.GENRES); + + // 6. Then animate the buttons + setTimeout(() => { + buttonsOpacity.value = withSpring(1, { + damping: 14, + stiffness: 100 + }); + buttonsTranslateY.value = withSpring(0, { + damping: 18, + stiffness: 120 + }); + }, ANIMATION_DELAY_CONSTANTS.BUTTONS); + + // 7. Finally animate the content section + setTimeout(() => { + contentTranslateY.value = withSpring(0, { + damping: 25, + mass: 1, + stiffness: 100 + }); + }, ANIMATION_DELAY_CONSTANTS.CONTENT); + }, 50); // Small timeout to ensure component is fully mounted + + return () => clearTimeout(animationTimeout); + }, []); + + // Effect to animate watch progress when it changes + useEffect(() => { + if (watchProgress && watchProgress.duration > 0) { + watchProgressOpacity.value = withSpring(1, { + mass: 0.2, + stiffness: 100, + damping: 14 + }); + watchProgressScaleY.value = withSpring(1, { + mass: 0.3, + stiffness: 120, + damping: 18 + }); + } else { + watchProgressOpacity.value = withSpring(0, { + mass: 0.2, + stiffness: 100, + damping: 14 + }); + watchProgressScaleY.value = withSpring(0, { + mass: 0.3, + stiffness: 120, + damping: 18 + }); + } + }, [watchProgress, watchProgressOpacity, watchProgressScaleY]); + + // Effect to animate logo when it's available + const animateLogo = (hasLogo: boolean) => { + if (hasLogo) { + logoOpacity.value = withTiming(1, { + duration: 500, + easing: Easing.out(Easing.ease) + }); + } else { + logoOpacity.value = withTiming(0, { + duration: 200, + easing: Easing.in(Easing.ease) + }); + } + }; + + // Scroll handler + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + const rawScrollY = event.contentOffset.y; + scrollY.value = rawScrollY; + + // Apply spring-like damping for smoother transitions + dampedScrollY.value = withTiming(rawScrollY, { + duration: 300, + easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve + }); + + // Update header opacity based on scroll position + const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer + if (rawScrollY > headerThreshold) { + headerOpacity.value = withTiming(1, { duration: 200 }); + headerElementsY.value = withTiming(0, { duration: 300 }); + headerElementsOpacity.value = withTiming(1, { duration: 450 }); + } else { + headerOpacity.value = withTiming(0, { duration: 150 }); + headerElementsY.value = withTiming(-10, { duration: 200 }); + headerElementsOpacity.value = withTiming(0, { duration: 200 }); + } + }, + }); + + return { + // Animated values + screenScale, + screenOpacity, + heroHeight, + heroScale, + heroOpacity, + contentTranslateY, + logoOpacity, + logoScale, + watchProgressOpacity, + watchProgressScaleY, + genresOpacity, + genresTranslateY, + buttonsOpacity, + buttonsTranslateY, + scrollY, + dampedScrollY, + headerOpacity, + headerElementsY, + headerElementsOpacity, + + // Functions + scrollHandler, + animateLogo, + }; +}; \ No newline at end of file diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts new file mode 100644 index 00000000..3ee0a945 --- /dev/null +++ b/src/hooks/useMetadataAssets.ts @@ -0,0 +1,510 @@ +import { useState, useEffect, useRef } from 'react'; +import { logger } from '../utils/logger'; +import { TMDBService } from '../services/tmdbService'; +import { isMetahubUrl, isTmdbUrl } from '../utils/logoUtils'; + +export const useMetadataAssets = ( + metadata: any, + id: string, + type: string, + imdbId: string | null, + settings: any, + setMetadata: (metadata: any) => void +) => { + // State for banner image + const [bannerImage, setBannerImage] = useState(null); + const [loadingBanner, setLoadingBanner] = useState(false); + const forcedBannerRefreshDone = useRef(false); + + // State for logo loading + const [logoLoadError, setLogoLoadError] = useState(false); + const logoFetchInProgress = useRef(false); + const logoRefreshCounter = useRef(0); + const MAX_LOGO_REFRESHES = 2; + const forcedLogoRefreshDone = useRef(false); + + // For TMDB ID tracking + const [foundTmdbId, setFoundTmdbId] = useState(null); + + // Effect to force-refresh the logo when it doesn't match the preference + useEffect(() => { + if (metadata?.logo && !forcedLogoRefreshDone.current) { + const currentLogoIsMetahub = isMetahubUrl(metadata.logo); + const currentLogoIsTmdb = isTmdbUrl(metadata.logo); + const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + + // Check if logo source doesn't match preference + if ((preferenceIsMetahub && !currentLogoIsMetahub) || + (!preferenceIsMetahub && !currentLogoIsTmdb)) { + logger.log(`[useMetadataAssets] Initial load: Logo source doesn't match preference. Forcing refresh.`); + + // Clear logo to force a new fetch according to preference + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: undefined + })); + } + + // Mark that we've checked this so we don't endlessly loop + forcedLogoRefreshDone.current = true; + } + }, [metadata?.logo, settings.logoSourcePreference, setMetadata]); + + // Reset logo load error when metadata changes + useEffect(() => { + setLogoLoadError(false); + }, [metadata?.logo]); + + // Force refresh logo when logo preference changes - only when preference actually changes + useEffect(() => { + // Reset the counter when preference actually changes + if (logoRefreshCounter.current === 0) { + logoRefreshCounter.current = 1; // Mark that we've started a refresh cycle + + // Only clear logo if we already have metadata with a logo + if (metadata?.logo) { + // Check if the current logo source doesn't match the preference + const currentLogoIsMetahub = isMetahubUrl(metadata.logo); + const currentLogoIsTmdb = isTmdbUrl(metadata.logo); + const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; + + // Only refresh if the current logo source clearly doesn't match the preference + const needsRefresh = (preferenceIsMetahub && currentLogoIsTmdb) || + (preferenceIsTmdb && currentLogoIsMetahub); + + if (needsRefresh) { + logger.log(`[useMetadataAssets] Logo preference (${settings.logoSourcePreference}) doesn't match current logo source, triggering one-time refresh`); + + // Prevent endless refreshes + if (logoRefreshCounter.current < MAX_LOGO_REFRESHES) { + logoRefreshCounter.current++; + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: undefined + })); + } else { + logger.warn(`[useMetadataAssets] Maximum logo refreshes (${MAX_LOGO_REFRESHES}) reached, stopping to prevent loop`); + } + } else { + logger.log(`[useMetadataAssets] Logo source already matches preference (${settings.logoSourcePreference}), no refresh needed`); + logoRefreshCounter.current = 0; // Reset for future changes + } + } + } else { + logoRefreshCounter.current++; + logger.log(`[useMetadataAssets] Logo refresh already in progress (${logoRefreshCounter.current}/${MAX_LOGO_REFRESHES})`); + + // Reset counter after max refreshes to allow future preference changes to work + if (logoRefreshCounter.current >= MAX_LOGO_REFRESHES) { + logger.warn(`[useMetadataAssets] Maximum refreshes reached, resetting counter`); + // After a timeout to avoid immediate re-triggering + setTimeout(() => { + logoRefreshCounter.current = 0; + }, 1000); + } + } + }, [settings.logoSourcePreference, metadata?.logo, setMetadata]); + + // Add effect to track when logo source matches preference + useEffect(() => { + if (metadata?.logo) { + const currentLogoIsMetahub = isMetahubUrl(metadata.logo); + const currentLogoIsTmdb = isTmdbUrl(metadata.logo); + const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; + + // Check if current logo source matches preference + const logoSourceMatches = (preferenceIsMetahub && currentLogoIsMetahub) || + (preferenceIsTmdb && currentLogoIsTmdb); + + if (logoSourceMatches) { + logger.log(`[useMetadataAssets] Logo source (${currentLogoIsMetahub ? 'Metahub' : 'TMDB'}) now matches preference (${settings.logoSourcePreference}), refresh complete`); + logoRefreshCounter.current = 0; // Reset counter since we've achieved our goal + } + } + }, [metadata?.logo, settings.logoSourcePreference]); + + // Fetch logo immediately for TMDB content - with guard against recursive updates + useEffect(() => { + // Guard against infinite loops by checking if we're already fetching + if (metadata && !metadata.logo && !logoFetchInProgress.current) { + console.log('[useMetadataAssets] Current settings:', JSON.stringify(settings)); + console.log('[useMetadataAssets] Current metadata:', JSON.stringify(metadata, null, 2)); + + const fetchLogo = async () => { + // Set fetch in progress flag + logoFetchInProgress.current = true; + + try { + // Get logo source preference from settings + const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set + + console.log(`[useMetadataAssets] Using logo preference: ${logoPreference}, TMDB first: ${logoPreference === 'tmdb'}`); + logger.log(`[useMetadataAssets] Logo source preference: ${logoPreference}`); + + // First source based on preference + if (logoPreference === 'metahub') { + // Try to get logo from Metahub first + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + + logger.log(`[useMetadataAssets] Attempting to fetch logo from Metahub for ${imdbId}`); + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[useMetadataAssets] Successfully fetched logo from Metahub: + - Content ID: ${id} + - Content Type: ${type} + - Logo URL: ${metahubUrl} + `); + + // Update metadata with Metahub logo + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: metahubUrl + })); + + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; + return; // Exit if Metahub logo was found + } else { + logger.warn(`[useMetadataAssets] Metahub logo request failed with status ${response.status}`); + } + } catch (metahubError) { + logger.warn(`[useMetadataAssets] Failed to fetch logo from Metahub:`, metahubError); + } + + // If Metahub fails, try TMDB as fallback + if (id.startsWith('tmdb:')) { + const tmdbId = id.split(':')[1]; + const tmdbType = type === 'series' ? 'tv' : 'movie'; + + logger.log(`[useMetadataAssets] Attempting to fetch logo from TMDB as fallback for ${tmdbType} (ID: ${tmdbId})`); + + const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId); + + if (logoUrl) { + logger.log(`[useMetadataAssets] Successfully fetched logo from TMDB: + - Content Type: ${tmdbType} + - TMDB ID: ${tmdbId} + - Logo URL: ${logoUrl} + `); + + // Update metadata with TMDB logo + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: logoUrl + })); + + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; + return; // Exit if TMDB logo was found + } else { + // If both Metahub and TMDB fail, use the title as text instead of a logo + logger.warn(`[useMetadataAssets] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); + + // Leave logo as null/undefined to trigger fallback to text + } + } + } else { // TMDB first + let tmdbLogoUrl: string | null = null; + + // 1. Attempt to fetch TMDB logo + if (id.startsWith('tmdb:')) { + const tmdbId = id.split(':')[1]; + const tmdbType = type === 'series' ? 'tv' : 'movie'; + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + + logger.log(`[useMetadataAssets] Attempting to fetch logo from TMDB for ${tmdbType} (ID: ${tmdbId}, preferred language: ${preferredLanguage})`); + try { + const tmdbService = TMDBService.getInstance(); + tmdbLogoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); + + if (tmdbLogoUrl) { + logger.log(`[useMetadataAssets] Successfully fetched logo from TMDB: ${tmdbLogoUrl}`); + } else { + logger.warn(`[useMetadataAssets] No logo found from TMDB for ${type} (ID: ${tmdbId})`); + } + } catch (error) { + logger.error(`[useMetadataAssets] Error fetching TMDB logo for ID ${tmdbId}:`, error); + } + } else if (imdbId) { + // If we have IMDB ID but no direct TMDB ID, try to find TMDB ID + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + logger.log(`[useMetadataAssets] Content has IMDB ID (${imdbId}), looking up TMDB ID for TMDB logo, preferred language: ${preferredLanguage}`); + try { + const tmdbService = TMDBService.getInstance(); + const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); + + if (foundTmdbId) { + logger.log(`[useMetadataAssets] Found TMDB ID ${foundTmdbId} for IMDB ID ${imdbId}`); + setFoundTmdbId(String(foundTmdbId)); // Save for banner fetching + + tmdbLogoUrl = await tmdbService.getContentLogo(type === 'series' ? 'tv' : 'movie', foundTmdbId.toString(), preferredLanguage); + + if (tmdbLogoUrl) { + logger.log(`[useMetadataAssets] Successfully fetched logo from TMDB via IMDB lookup: ${tmdbLogoUrl}`); + } else { + logger.warn(`[useMetadataAssets] No logo found from TMDB via IMDB lookup for ${type} (IMDB: ${imdbId})`); + } + } else { + logger.warn(`[useMetadataAssets] Could not find TMDB ID for IMDB ID ${imdbId}`); + } + } catch (error) { + logger.error(`[useMetadataAssets] Error finding TMDB ID or fetching logo for IMDB ID ${imdbId}:`, error); + } + } + + // 2. If TMDB logo was fetched successfully, update and return + if (tmdbLogoUrl) { + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: tmdbLogoUrl + })); + logoFetchInProgress.current = false; + return; + } + + // 3. If TMDB failed, try Metahub as fallback + logger.log(`[useMetadataAssets] TMDB logo fetch failed or not applicable. Attempting Metahub fallback.`); + if (imdbId) { + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + logger.log(`[useMetadataAssets] Attempting to fetch logo from Metahub as fallback for ${imdbId}`); + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[useMetadataAssets] Successfully fetched fallback logo from Metahub: ${metahubUrl}`); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: metahubUrl })); + } else { + logger.warn(`[useMetadataAssets] Metahub fallback failed. Using title text.`); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } + } catch (metahubError) { + logger.warn(`[useMetadataAssets] Failed to fetch fallback logo from Metahub:`, metahubError); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } + } else { + // No IMDB ID for Metahub fallback + logger.warn(`[useMetadataAssets] No IMDB ID for Metahub fallback. Using title text.`); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } + } + } catch (error) { + logger.error('[useMetadataAssets] Failed to fetch logo from all sources:', { + error, + contentId: id, + contentType: type + }); + // Fallback to text on general error + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } finally { + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; + } + }; + + fetchLogo(); + } else if (logoFetchInProgress.current) { + console.log('[useMetadataAssets] Logo fetch already in progress, skipping'); + } else if (metadata?.logo) { + logger.log(`[useMetadataAssets] Using existing logo from metadata: + - Content ID: ${id} + - Content Type: ${type} + - Logo URL: ${metadata.logo} + - Source: ${isMetahubUrl(metadata.logo) ? 'Metahub' : (isTmdbUrl(metadata.logo) ? 'TMDB' : 'Other')} + `); + } + }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference]); + + // Fetch banner image based on logo source preference + useEffect(() => { + const fetchBanner = async () => { + if (metadata) { + setLoadingBanner(true); + + // Clear the banner initially when starting a preference-driven fetch + setBannerImage(null); + + let finalBanner: string | null = metadata.banner || metadata.poster; // Default fallback + const preference = settings.logoSourcePreference || 'metahub'; + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + const apiKey = '439c478a771f35c05022f9feabcca01c'; // Re-using API key + + // Extract IDs + let currentTmdbId = null; + if (id.startsWith('tmdb:')) { + currentTmdbId = id.split(':')[1]; + } else if (foundTmdbId) { + currentTmdbId = foundTmdbId; + } else if ((metadata as any).tmdbId) { + currentTmdbId = (metadata as any).tmdbId; + } + + const currentImdbId = imdbId; + const contentType = type === 'series' ? 'tv' : 'movie'; + + logger.log(`[useMetadataAssets] Fetching banner with preference: ${preference}, language: ${preferredLanguage}, TMDB ID: ${currentTmdbId}, IMDB ID: ${currentImdbId}`); + + try { + if (preference === 'tmdb') { + // 1. Try TMDB first + let tmdbBannerUrl: string | null = null; + if (currentTmdbId) { + logger.log(`[useMetadataAssets] Attempting TMDB banner fetch with ID: ${currentTmdbId}`); + try { + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}&include_image_language=${preferredLanguage},en,null`); + const imagesData = await response.json(); + + if (imagesData.backdrops && imagesData.backdrops.length > 0) { + // Try to find backdrop in preferred language first + let backdropPath = null; + + if (preferredLanguage !== 'en') { + const preferredBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === preferredLanguage); + if (preferredBackdrop) { + backdropPath = preferredBackdrop.file_path; + logger.log(`[useMetadataAssets] Found ${preferredLanguage} backdrop for ID: ${currentTmdbId}`); + } + } + + // Fall back to English backdrop + if (!backdropPath) { + const englishBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === 'en'); + if (englishBackdrop) { + backdropPath = englishBackdrop.file_path; + logger.log(`[useMetadataAssets] Found English backdrop for ID: ${currentTmdbId}`); + } else { + // Last resort: use the first backdrop + backdropPath = imagesData.backdrops[0].file_path; + logger.log(`[useMetadataAssets] Using first available backdrop for ID: ${currentTmdbId}`); + } + } + + tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; + logger.log(`[useMetadataAssets] Found TMDB banner via images endpoint: ${tmdbBannerUrl}`); + } else { + // Add log for when no backdrops are found + logger.warn(`[useMetadataAssets] TMDB API successful, but no backdrops found for ID: ${currentTmdbId}`); + } + } catch (err) { + logger.error(`[useMetadataAssets] Error fetching TMDB banner via images endpoint:`, err); + } + } else { + // Add log for when no TMDB ID is available + logger.warn(`[useMetadataAssets] No TMDB ID available to fetch TMDB banner.`); + } + + if (tmdbBannerUrl) { + // TMDB SUCCESS: Set banner and EXIT + finalBanner = tmdbBannerUrl; + logger.log(`[useMetadataAssets] Setting final banner to TMDB source: ${finalBanner}`); + setBannerImage(finalBanner); + setLoadingBanner(false); + forcedBannerRefreshDone.current = true; + return; // <-- Exit here, don't attempt fallback + } else { + // TMDB FAILED: Proceed to Metahub fallback + logger.log(`[useMetadataAssets] TMDB banner failed, trying Metahub fallback.`); + if (currentImdbId) { + const metahubBannerUrl = `https://images.metahub.space/background/medium/${currentImdbId}/img`; + try { + const metahubResponse = await fetch(metahubBannerUrl, { method: 'HEAD' }); + if (metahubResponse.ok) { + finalBanner = metahubBannerUrl; + logger.log(`[useMetadataAssets] Found Metahub banner as fallback: ${finalBanner}`); + } + } catch (err) { + logger.error(`[useMetadataAssets] Error fetching Metahub fallback banner:`, err); + } + } + } + } else { // Preference is Metahub + // 1. Try Metahub first + let metahubBannerUrl: string | null = null; + if (currentImdbId) { + const url = `https://images.metahub.space/background/medium/${currentImdbId}/img`; + try { + const metahubResponse = await fetch(url, { method: 'HEAD' }); + if (metahubResponse.ok) { + metahubBannerUrl = url; + logger.log(`[useMetadataAssets] Found Metahub banner: ${metahubBannerUrl}`); + } + } catch (err) { + logger.error(`[useMetadataAssets] Error fetching Metahub banner:`, err); + } + } + + if (metahubBannerUrl) { + // METAHUB SUCCESS: Set banner and EXIT + finalBanner = metahubBannerUrl; + logger.log(`[useMetadataAssets] Setting final banner to Metahub source: ${finalBanner}`); + setBannerImage(finalBanner); + setLoadingBanner(false); + forcedBannerRefreshDone.current = true; + return; // <-- Exit here, don't attempt fallback + } else { + // METAHUB FAILED: Proceed to TMDB fallback + logger.log(`[useMetadataAssets] Metahub banner failed, trying TMDB fallback.`); + if (currentTmdbId) { + try { + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}`); + const imagesData = await response.json(); + + if (imagesData.backdrops && imagesData.backdrops.length > 0) { + const backdropPath = imagesData.backdrops[0].file_path; + finalBanner = `https://image.tmdb.org/t/p/original${backdropPath}`; + logger.log(`[useMetadataAssets] Found TMDB banner as fallback: ${finalBanner}`); + } + } catch (err) { + logger.error(`[useMetadataAssets] Error fetching TMDB fallback banner:`, err); + } + } + } + } + + // Set the final determined banner (could be fallback or initial default) + setBannerImage(finalBanner); + logger.log(`[useMetadataAssets] Final banner set after fallbacks (if any): ${finalBanner}`); + + } catch (error) { + logger.error(`[useMetadataAssets] General error fetching banner:`, error); + // Fallback to initial banner on general error + setBannerImage(metadata.banner || metadata.poster); + } finally { + // Only set loading to false here if we didn't exit early + setLoadingBanner(false); + forcedBannerRefreshDone.current = true; // Mark refresh as done + } + } + }; + + // Only run fetchBanner if metadata exists and preference/content might have changed + // The dependencies array handles triggering this effect + fetchBanner(); + + }, [metadata, id, type, imdbId, settings.logoSourcePreference, foundTmdbId]); + + // Reset forced refresh when preference changes + useEffect(() => { + if (forcedBannerRefreshDone.current) { + logger.log(`[useMetadataAssets] Logo preference changed, resetting banner refresh flag`); + forcedBannerRefreshDone.current = false; + // Clear the banner image immediately to prevent showing the wrong source briefly + setBannerImage(null); + // This will trigger the banner fetch effect to run again + } + }, [settings.logoSourcePreference]); + + return { + bannerImage, + loadingBanner, + logoLoadError, + foundTmdbId, + setLogoLoadError, + setBannerImage, + }; +}; \ No newline at end of file diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts new file mode 100644 index 00000000..7c71539b --- /dev/null +++ b/src/hooks/useWatchProgress.ts @@ -0,0 +1,216 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import { logger } from '../utils/logger'; +import { storageService } from '../services/storageService'; + +interface WatchProgressData { + currentTime: number; + duration: number; + lastUpdated: number; + episodeId?: string; +} + +export const useWatchProgress = ( + id: string, + type: 'movie' | 'series', + episodeId?: string, + episodes: any[] = [] +) => { + const [watchProgress, setWatchProgress] = useState(null); + + // Function to get episode details from episodeId + const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => { + // Try to parse from format "seriesId:season:episode" + const parts = episodeId.split(':'); + if (parts.length === 3) { + const [, seasonNum, episodeNum] = parts; + // Find episode in our local episodes array + const episode = episodes.find( + ep => ep.season_number === parseInt(seasonNum) && + ep.episode_number === parseInt(episodeNum) + ); + + if (episode) { + return { + seasonNumber: seasonNum, + episodeNumber: episodeNum, + episodeName: episode.name + }; + } + } + + // If not found by season/episode, try stremioId + const episodeByStremioId = episodes.find(ep => ep.stremioId === episodeId); + if (episodeByStremioId) { + return { + seasonNumber: episodeByStremioId.season_number.toString(), + episodeNumber: episodeByStremioId.episode_number.toString(), + episodeName: episodeByStremioId.name + }; + } + + return null; + }, [episodes]); + + // Load watch progress + const loadWatchProgress = useCallback(async () => { + try { + if (id && type) { + if (type === 'series') { + const allProgress = await storageService.getAllWatchProgress(); + + // Function to get episode number from episodeId + const getEpisodeNumber = (epId: string) => { + const parts = epId.split(':'); + if (parts.length === 3) { + return { + season: parseInt(parts[1]), + episode: parseInt(parts[2]) + }; + } + return null; + }; + + // Get all episodes for this series with progress + const seriesProgresses = Object.entries(allProgress) + .filter(([key]) => key.includes(`${type}:${id}:`)) + .map(([key, value]) => ({ + episodeId: key.split(`${type}:${id}:`)[1], + progress: value + })) + .filter(({ episodeId, progress }) => { + const progressPercent = (progress.currentTime / progress.duration) * 100; + return progressPercent > 0; + }); + + // If we have a specific episodeId in route params + if (episodeId) { + const progress = await storageService.getWatchProgress(id, type, episodeId); + if (progress) { + const progressPercent = (progress.currentTime / progress.duration) * 100; + + // If current episode is finished (≥95%), try to find next unwatched episode + if (progressPercent >= 95) { + const currentEpNum = getEpisodeNumber(episodeId); + if (currentEpNum && episodes.length > 0) { + // Find the next episode + const nextEpisode = episodes.find(ep => { + // First check in same season + if (ep.season_number === currentEpNum.season && ep.episode_number > currentEpNum.episode) { + const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; + const epProgress = seriesProgresses.find(p => p.episodeId === epId); + if (!epProgress) return true; + const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; + return percent < 95; + } + // Then check next seasons + if (ep.season_number > currentEpNum.season) { + const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; + const epProgress = seriesProgresses.find(p => p.episodeId === epId); + if (!epProgress) return true; + const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; + return percent < 95; + } + return false; + }); + + if (nextEpisode) { + const nextEpisodeId = nextEpisode.stremioId || + `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; + const nextProgress = await storageService.getWatchProgress(id, type, nextEpisodeId); + if (nextProgress) { + setWatchProgress({ ...nextProgress, episodeId: nextEpisodeId }); + } else { + setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: nextEpisodeId }); + } + return; + } + } + // If no next episode found or current episode is finished, show no progress + setWatchProgress(null); + return; + } + + // If current episode is not finished, show its progress + setWatchProgress({ ...progress, episodeId }); + } else { + setWatchProgress(null); + } + } else { + // Find the first unfinished episode + const unfinishedEpisode = episodes.find(ep => { + const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; + const progress = seriesProgresses.find(p => p.episodeId === epId); + if (!progress) return true; + const percent = (progress.progress.currentTime / progress.progress.duration) * 100; + return percent < 95; + }); + + if (unfinishedEpisode) { + const epId = unfinishedEpisode.stremioId || + `${id}:${unfinishedEpisode.season_number}:${unfinishedEpisode.episode_number}`; + const progress = await storageService.getWatchProgress(id, type, epId); + if (progress) { + setWatchProgress({ ...progress, episodeId: epId }); + } else { + setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: epId }); + } + } else { + setWatchProgress(null); + } + } + } else { + // For movies + const progress = await storageService.getWatchProgress(id, type, episodeId); + if (progress && progress.currentTime > 0) { + const progressPercent = (progress.currentTime / progress.duration) * 100; + if (progressPercent >= 95) { + setWatchProgress(null); + } else { + setWatchProgress({ ...progress, episodeId }); + } + } else { + setWatchProgress(null); + } + } + } + } catch (error) { + logger.error('[useWatchProgress] Error loading watch progress:', error); + setWatchProgress(null); + } + }, [id, type, episodeId, episodes]); + + // Function to get play button text based on watch progress + const getPlayButtonText = useCallback(() => { + if (!watchProgress || watchProgress.currentTime <= 0) { + return 'Play'; + } + + // Consider episode complete if progress is >= 95% + const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + if (progressPercent >= 95) { + return 'Play'; + } + + return 'Resume'; + }, [watchProgress]); + + // Initial load + useEffect(() => { + loadWatchProgress(); + }, [loadWatchProgress]); + + // Refresh when screen comes into focus + useFocusEffect( + useCallback(() => { + loadWatchProgress(); + }, [loadWatchProgress]) + ); + + return { + watchProgress, + getEpisodeDetails, + getPlayButtonText, + loadWatchProgress + }; +}; \ No newline at end of file diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index a6ca4d1a..881bdd04 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -1,212 +1,44 @@ -import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { View, Text, StyleSheet, - ScrollView, - TouchableOpacity, - ActivityIndicator, - useColorScheme, StatusBar, - ImageBackground, + ActivityIndicator, Dimensions, - Platform, - TouchableWithoutFeedback, - NativeSyntheticEvent, - NativeScrollEvent, + TouchableOpacity, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native'; +import { useRoute, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Image } from 'expo-image'; -import { BlurView as ExpoBlurView } from 'expo-blur'; -import { BlurView as CommunityBlurView } from '@react-native-community/blur'; import * as Haptics from 'expo-haptics'; import { colors } from '../styles/colors'; import { useMetadata } from '../hooks/useMetadata'; -import { CastSection as OriginalCastSection } from '../components/metadata/CastSection'; -import { SeriesContent as OriginalSeriesContent } from '../components/metadata/SeriesContent'; -import { MovieContent as OriginalMovieContent } from '../components/metadata/MovieContent'; -import { MoreLikeThisSection as OriginalMoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; -import { RatingsSection as OriginalRatingsSection } from '../components/metadata/RatingsSection'; -import { StreamingContent } from '../services/catalogService'; -import { GroupedStreams } from '../types/streams'; -import { TMDBEpisode } from '../services/tmdbService'; -import { Cast } from '../types/cast'; +import { CastSection } from '../components/metadata/CastSection'; +import { SeriesContent } from '../components/metadata/SeriesContent'; +import { MovieContent } from '../components/metadata/MovieContent'; +import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; +import { RatingsSection } from '../components/metadata/RatingsSection'; import { RouteParams, Episode } from '../types/metadata'; import Animated, { useAnimatedStyle, - withTiming, - useSharedValue, - Easing, - FadeInDown, interpolate, Extrapolate, - withSpring, - FadeIn, - runOnJS, - Layout, - useAnimatedScrollHandler, } from 'react-native-reanimated'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; -import { TMDBService } from '../services/tmdbService'; -import { storageService } from '../services/storageService'; -import { logger } from '../utils/logger'; -import { useGenres } from '../contexts/GenreContext'; -import { isValidMetahubLogo, isMetahubUrl, isTmdbUrl, fetchBannerWithPreference } from '../utils/logoUtils'; import { useSettings } from '../hooks/useSettings'; -const { width, height } = Dimensions.get('window'); +// Import our new components and hooks +import HeroSection from '../components/metadata/HeroSection'; +import FloatingHeader from '../components/metadata/FloatingHeader'; +import MetadataDetails from '../components/metadata/MetadataDetails'; +import { useMetadataAnimations } from '../hooks/useMetadataAnimations'; +import { useMetadataAssets } from '../hooks/useMetadataAssets'; +import { useWatchProgress } from '../hooks/useWatchProgress'; -// Memoize child components -const CastSection = React.memo(OriginalCastSection); -const SeriesContent = React.memo(OriginalSeriesContent); -const MovieContent = React.memo(OriginalMovieContent); -const MoreLikeThisSection = React.memo(OriginalMoreLikeThisSection); -const RatingsSection = React.memo(OriginalRatingsSection); - -// Animation constants -const springConfig = { - damping: 20, - mass: 1, - stiffness: 100 -}; - -// Animation timing constants for staggered appearance -const ANIMATION_DELAY_CONSTANTS = { - HERO: 100, - LOGO: 250, - PROGRESS: 350, - GENRES: 400, - BUTTONS: 450, - CONTENT: 500 -}; - -// Add debug log for storageService -logger.log('[MetadataScreen] StorageService instance:', storageService); - -// Memoized ActionButtons Component -const ActionButtons = React.memo(({ - handleShowStreams, - toggleLibrary, - inLibrary, - type, - id, - navigation, - playButtonText, - animatedStyle -}: { - handleShowStreams: () => void; - toggleLibrary: () => void; - inLibrary: boolean; - type: 'movie' | 'series'; - id: string; - navigation: NavigationProp; - playButtonText: string; - animatedStyle: any; -}) => { - // Add wrapper for play button with haptic feedback - const handlePlay = () => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - handleShowStreams(); - }; - - return ( - - - - - {playButtonText} - - - - - - - {inLibrary ? 'Saved' : 'Save'} - - - - {type === 'series' && ( - { - const tmdb = TMDBService.getInstance(); - const tmdbId = await tmdb.extractTMDBIdFromStremioId(id); - if (tmdbId) { - navigation.navigate('ShowRatings', { showId: tmdbId }); - } else { - logger.error('Could not find TMDB ID for show'); - } - }} - > - - - )} - - ); -}); - -// Memoized WatchProgress Component -const WatchProgressDisplay = React.memo(({ - watchProgress, - type, - getEpisodeDetails, - animatedStyle -}: { - watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null; - type: 'movie' | 'series'; - getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; - animatedStyle: any; -}) => { - if (!watchProgress || watchProgress.duration === 0) { - return null; - } - - const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; - const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString(); - let episodeInfo = ''; - - if (type === 'series' && watchProgress.episodeId) { - const details = getEpisodeDetails(watchProgress.episodeId); - if (details) { - episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; - } - } - - return ( - - - - - - {progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime} - - - ); -}); +const { height } = Dimensions.get('window'); const MetadataScreen = () => { const route = useRoute, string>>(); @@ -216,18 +48,8 @@ const MetadataScreen = () => { // Add settings hook const { settings } = useSettings(); - // Add a flag to track if we need to do a forced initial logo refresh - const forcedLogoRefreshDone = useRef(false); - - // Add state for custom banner - const [bannerImage, setBannerImage] = useState(null); - const forcedBannerRefreshDone = useRef(false); - const [loadingBanner, setLoadingBanner] = useState(false); - - // Add debug log for settings when component mounts - useEffect(() => { - logger.log(`[MetadataScreen] Component mounted with logo preference setting: ${settings.logoSourcePreference}`); - }, [settings.logoSourcePreference]); + // Get safe area insets + const { top: safeAreaTop } = useSafeAreaInsets(); const { metadata, @@ -249,331 +71,22 @@ const MetadataScreen = () => { imdbId, } = useMetadata({ id, type }); - // Force an initial logo refresh when component mounts and logo doesn't match preference - useEffect(() => { - if (metadata?.logo && !forcedLogoRefreshDone.current) { - const currentLogoIsMetahub = isMetahubUrl(metadata.logo); - const currentLogoIsTmdb = isTmdbUrl(metadata.logo); - const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + // Use our new hooks + const { + watchProgress, + getEpisodeDetails, + getPlayButtonText, + } = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes); - // Check if logo source doesn't match preference - if ((preferenceIsMetahub && !currentLogoIsMetahub) || - (!preferenceIsMetahub && !currentLogoIsTmdb)) { - logger.log(`[MetadataScreen] Initial load: Logo source doesn't match preference. Forcing refresh.`); - - // Clear logo to force a new fetch according to preference - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: undefined - })); - } - - // Mark that we've checked this so we don't endlessly loop - forcedLogoRefreshDone.current = true; - } - }, [metadata?.logo, settings.logoSourcePreference, setMetadata]); + const { + bannerImage, + loadingBanner, + logoLoadError, + setLogoLoadError, + setBannerImage, + } = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata); - // Store found TMDB ID for banner fetching - const [foundTmdbId, setFoundTmdbId] = useState(null); - - // Fetch banner image based on logo source preference - useEffect(() => { - const fetchBanner = async () => { - if (metadata) { - setLoadingBanner(true); - - // Clear the banner initially when starting a preference-driven fetch - setBannerImage(null); - - let finalBanner: string | null = metadata.banner || metadata.poster; // Default fallback - const preference = settings.logoSourcePreference || 'metahub'; - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - const apiKey = '439c478a771f35c05022f9feabcca01c'; // Re-using API key - - // Extract IDs - let currentTmdbId = null; - if (id.startsWith('tmdb:')) { - currentTmdbId = id.split(':')[1]; - } else if (foundTmdbId) { - currentTmdbId = foundTmdbId; - } else if ((metadata as any).tmdbId) { - currentTmdbId = (metadata as any).tmdbId; - } - - const currentImdbId = imdbId; - const contentType = type === 'series' ? 'tv' : 'movie'; - - logger.log(`[MetadataScreen] Fetching banner with preference: ${preference}, language: ${preferredLanguage}, TMDB ID: ${currentTmdbId}, IMDB ID: ${currentImdbId}`); - - try { - if (preference === 'tmdb') { - // 1. Try TMDB first - let tmdbBannerUrl: string | null = null; - if (currentTmdbId) { - logger.log(`[MetadataScreen] Attempting TMDB banner fetch with ID: ${currentTmdbId}`); - try { - const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}&include_image_language=${preferredLanguage},en,null`); - const imagesData = await response.json(); - - if (imagesData.backdrops && imagesData.backdrops.length > 0) { - // Try to find backdrop in preferred language first - let backdropPath = null; - - if (preferredLanguage !== 'en') { - const preferredBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === preferredLanguage); - if (preferredBackdrop) { - backdropPath = preferredBackdrop.file_path; - logger.log(`[MetadataScreen] Found ${preferredLanguage} backdrop for ID: ${currentTmdbId}`); - } - } - - // Fall back to English backdrop - if (!backdropPath) { - const englishBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === 'en'); - if (englishBackdrop) { - backdropPath = englishBackdrop.file_path; - logger.log(`[MetadataScreen] Found English backdrop for ID: ${currentTmdbId}`); - } else { - // Last resort: use the first backdrop - backdropPath = imagesData.backdrops[0].file_path; - logger.log(`[MetadataScreen] Using first available backdrop for ID: ${currentTmdbId}`); - } - } - - tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; - logger.log(`[MetadataScreen] Found TMDB banner via images endpoint: ${tmdbBannerUrl}`); - } else { - // Add log for when no backdrops are found - logger.warn(`[MetadataScreen] TMDB API successful, but no backdrops found for ID: ${currentTmdbId}`); - } - } catch (err) { - logger.error(`[MetadataScreen] Error fetching TMDB banner via images endpoint:`, err); - } - } else { - // Add log for when no TMDB ID is available - logger.warn(`[MetadataScreen] No TMDB ID available to fetch TMDB banner.`); - } - - if (tmdbBannerUrl) { - // TMDB SUCCESS: Set banner and EXIT - finalBanner = tmdbBannerUrl; - logger.log(`[MetadataScreen] Setting final banner to TMDB source: ${finalBanner}`); - setBannerImage(finalBanner); - setLoadingBanner(false); - forcedBannerRefreshDone.current = true; - return; // <-- Exit here, don't attempt fallback - } else { - // TMDB FAILED: Proceed to Metahub fallback - logger.log(`[MetadataScreen] TMDB banner failed, trying Metahub fallback.`); - if (currentImdbId) { - const metahubBannerUrl = `https://images.metahub.space/background/medium/${currentImdbId}/img`; - try { - const metahubResponse = await fetch(metahubBannerUrl, { method: 'HEAD' }); - if (metahubResponse.ok) { - finalBanner = metahubBannerUrl; - logger.log(`[MetadataScreen] Found Metahub banner as fallback: ${finalBanner}`); - } - } catch (err) { - logger.error(`[MetadataScreen] Error fetching Metahub fallback banner:`, err); - } - } - } - } else { // Preference is Metahub - // 1. Try Metahub first - let metahubBannerUrl: string | null = null; - if (currentImdbId) { - const url = `https://images.metahub.space/background/medium/${currentImdbId}/img`; - try { - const metahubResponse = await fetch(url, { method: 'HEAD' }); - if (metahubResponse.ok) { - metahubBannerUrl = url; - logger.log(`[MetadataScreen] Found Metahub banner: ${metahubBannerUrl}`); - } - } catch (err) { - logger.error(`[MetadataScreen] Error fetching Metahub banner:`, err); - } - } - - if (metahubBannerUrl) { - // METAHUB SUCCESS: Set banner and EXIT - finalBanner = metahubBannerUrl; - logger.log(`[MetadataScreen] Setting final banner to Metahub source: ${finalBanner}`); - setBannerImage(finalBanner); - setLoadingBanner(false); - forcedBannerRefreshDone.current = true; - return; // <-- Exit here, don't attempt fallback - } else { - // METAHUB FAILED: Proceed to TMDB fallback - logger.log(`[MetadataScreen] Metahub banner failed, trying TMDB fallback.`); - if (currentTmdbId) { - try { - const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}`); - const imagesData = await response.json(); - - if (imagesData.backdrops && imagesData.backdrops.length > 0) { - const backdropPath = imagesData.backdrops[0].file_path; - finalBanner = `https://image.tmdb.org/t/p/original${backdropPath}`; - logger.log(`[MetadataScreen] Found TMDB banner as fallback: ${finalBanner}`); - } - } catch (err) { - logger.error(`[MetadataScreen] Error fetching TMDB fallback banner:`, err); - } - } - } - } - - // Set the final determined banner (could be fallback or initial default) - setBannerImage(finalBanner); - logger.log(`[MetadataScreen] Final banner set after fallbacks (if any): ${finalBanner}`); - - } catch (error) { - logger.error(`[MetadataScreen] General error fetching banner:`, error); - // Fallback to initial banner on general error - setBannerImage(metadata.banner || metadata.poster); - } finally { - // Only set loading to false here if we didn't exit early - setLoadingBanner(false); - forcedBannerRefreshDone.current = true; // Mark refresh as done - } - } - }; - - // Only run fetchBanner if metadata exists and preference/content might have changed - // The dependencies array handles triggering this effect - fetchBanner(); - - }, [metadata, id, type, imdbId, settings.logoSourcePreference, foundTmdbId]); - - // Reset forced refresh when preference changes - useEffect(() => { - if (forcedBannerRefreshDone.current) { - logger.log(`[MetadataScreen] Logo preference changed, resetting banner refresh flag`); - forcedBannerRefreshDone.current = false; - // Clear the banner image immediately to prevent showing the wrong source briefly - setBannerImage(null); - // This will trigger the banner fetch effect to run again - } - }, [settings.logoSourcePreference]); - - // Get genres from context - const { genreMap, loadingGenres } = useGenres(); - - // Update the ref type to be compatible with Animated.ScrollView - const contentRef = useRef(null); - const [lastScrollTop, setLastScrollTop] = useState(0); - const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); - - // Get safe area insets - const { top: safeAreaTop } = useSafeAreaInsets(); - - // Animation values - const screenScale = useSharedValue(0.92); - const screenOpacity = useSharedValue(0); - const heroHeight = useSharedValue(height * 0.5); - const contentTranslateY = useSharedValue(60); - - // Additional animation values for staggered entrance - const heroScale = useSharedValue(1.05); - const heroOpacity = useSharedValue(0); - const genresOpacity = useSharedValue(0); - const genresTranslateY = useSharedValue(20); - const buttonsOpacity = useSharedValue(0); - const buttonsTranslateY = useSharedValue(30); - - // Add state for watch progress - const [watchProgress, setWatchProgress] = useState<{ - currentTime: number; - duration: number; - lastUpdated: number; - episodeId?: string; - } | null>(null); - - // Add state to track image load errors - const [logoLoadError, setLogoLoadError] = useState(false); - - // Reset logo load error when metadata changes - useEffect(() => { - setLogoLoadError(false); - }, [metadata?.logo]); - - // Add a ref to track logo fetch in progress - const logoFetchInProgress = useRef(false); - - // Add a ref to track logo preference changes to prevent infinite loops - const logoRefreshCounter = useRef(0); - const MAX_LOGO_REFRESHES = 2; - - // Force refresh logo when logo preference changes - only when preference actually changes - useEffect(() => { - // Reset the counter when preference actually changes - if (logoRefreshCounter.current === 0) { - logoRefreshCounter.current = 1; // Mark that we've started a refresh cycle - - // Only clear logo if we already have metadata with a logo - if (metadata?.logo) { - // Check if the current logo source doesn't match the preference - const currentLogoIsMetahub = isMetahubUrl(metadata.logo); - const currentLogoIsTmdb = isTmdbUrl(metadata.logo); - const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; - const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; - - // Only refresh if the current logo source clearly doesn't match the preference - const needsRefresh = (preferenceIsMetahub && currentLogoIsTmdb) || - (preferenceIsTmdb && currentLogoIsMetahub); - - if (needsRefresh) { - logger.log(`[MetadataScreen] Logo preference (${settings.logoSourcePreference}) doesn't match current logo source, triggering one-time refresh`); - - // Prevent endless refreshes - if (logoRefreshCounter.current < MAX_LOGO_REFRESHES) { - logoRefreshCounter.current++; - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: undefined - })); - } else { - logger.warn(`[MetadataScreen] Maximum logo refreshes (${MAX_LOGO_REFRESHES}) reached, stopping to prevent loop`); - } - } else { - logger.log(`[MetadataScreen] Logo source already matches preference (${settings.logoSourcePreference}), no refresh needed`); - logoRefreshCounter.current = 0; // Reset for future changes - } - } - } else { - logoRefreshCounter.current++; - logger.log(`[MetadataScreen] Logo refresh already in progress (${logoRefreshCounter.current}/${MAX_LOGO_REFRESHES})`); - - // Reset counter after max refreshes to allow future preference changes to work - if (logoRefreshCounter.current >= MAX_LOGO_REFRESHES) { - logger.warn(`[MetadataScreen] Maximum refreshes reached, resetting counter`); - // After a timeout to avoid immediate re-triggering - setTimeout(() => { - logoRefreshCounter.current = 0; - }, 1000); - } - } - }, [settings.logoSourcePreference, metadata?.logo, setMetadata]); - - // Add effect to track when logo source matches preference - useEffect(() => { - if (metadata?.logo) { - const currentLogoIsMetahub = isMetahubUrl(metadata.logo); - const currentLogoIsTmdb = isTmdbUrl(metadata.logo); - const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; - const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; - - // Check if current logo source matches preference - const logoSourceMatches = (preferenceIsMetahub && currentLogoIsMetahub) || - (preferenceIsTmdb && currentLogoIsTmdb); - - if (logoSourceMatches) { - logger.log(`[MetadataScreen] Logo source (${currentLogoIsMetahub ? 'Metahub' : 'TMDB'}) now matches preference (${settings.logoSourcePreference}), refresh complete`); - logoRefreshCounter.current = 0; // Reset counter since we've achieved our goal - } - } - }, [metadata?.logo, settings.logoSourcePreference]); + const animations = useMetadataAnimations(safeAreaTop, watchProgress); // Add wrapper for toggleLibrary that includes haptic feedback const handleToggleLibrary = useCallback(() => { @@ -601,482 +114,6 @@ const MetadataScreen = () => { }, 10); }, [handleSeasonChange]); - // Add new animated value for watch progress - const watchProgressOpacity = useSharedValue(0); - const watchProgressScaleY = useSharedValue(0); - - // Add animated value for logo - const logoOpacity = useSharedValue(0); - const logoScale = useSharedValue(0.9); - - // Add shared value for parallax effect - const scrollY = useSharedValue(0); - - // Create a dampened scroll value for smoother parallax - const dampedScrollY = useSharedValue(0); - - // Add shared value for floating header opacity - const headerOpacity = useSharedValue(0); - - // Add values for animated header elements - const headerElementsY = useSharedValue(-10); - const headerElementsOpacity = useSharedValue(0); - - // Debug log for route params - // logger.log('[MetadataScreen] Component mounted with route params:', { id, type, episodeId }); - - // Fetch logo immediately for TMDB content - with guard against recursive updates - useEffect(() => { - // Guard against infinite loops by checking if we're already fetching - if (metadata && !metadata.logo && !logoFetchInProgress.current) { - console.log('[MetadataScreen] Current settings:', JSON.stringify(settings)); - console.log('[MetadataScreen] Current metadata:', JSON.stringify(metadata, null, 2)); - - const fetchLogo = async () => { - // Set fetch in progress flag - logoFetchInProgress.current = true; - - try { - // Get logo source preference from settings - const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set - - console.log(`[MetadataScreen] Using logo preference: ${logoPreference}, TMDB first: ${logoPreference === 'tmdb'}`); - logger.log(`[MetadataScreen] Logo source preference: ${logoPreference}`); - - // First source based on preference - if (logoPreference === 'metahub') { - // Try to get logo from Metahub first - const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - - logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub for ${imdbId}`); - - try { - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - logger.log(`[MetadataScreen] Successfully fetched logo from Metahub: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metahubUrl} - `); - - // Update metadata with Metahub logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: metahubUrl - })); - - // Clear fetch in progress flag when done - logoFetchInProgress.current = false; - return; // Exit if Metahub logo was found - } else { - logger.warn(`[MetadataScreen] Metahub logo request failed with status ${response.status}`); - } - } catch (metahubError) { - logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); - } - - // If Metahub fails, try TMDB as fallback - if (id.startsWith('tmdb:')) { - const tmdbId = id.split(':')[1]; - const tmdbType = type === 'series' ? 'tv' : 'movie'; - - logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB as fallback for ${tmdbType} (ID: ${tmdbId})`); - - const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId); - - if (logoUrl) { - logger.log(`[MetadataScreen] Successfully fetched logo from TMDB: - - Content Type: ${tmdbType} - - TMDB ID: ${tmdbId} - - Logo URL: ${logoUrl} - `); - - // Update metadata with TMDB logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: logoUrl - })); - - // Clear fetch in progress flag when done - logoFetchInProgress.current = false; - return; // Exit if TMDB logo was found - } else { - // If both Metahub and TMDB fail, use the title as text instead of a logo - logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); - - // Leave logo as null/undefined to trigger fallback to text - } - } - } else { // TMDB first - let tmdbLogoUrl: string | null = null; - - // 1. Attempt to fetch TMDB logo - if (id.startsWith('tmdb:')) { - const tmdbId = id.split(':')[1]; - const tmdbType = type === 'series' ? 'tv' : 'movie'; - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - - logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB for ${tmdbType} (ID: ${tmdbId}, preferred language: ${preferredLanguage})`); - try { - const tmdbService = TMDBService.getInstance(); - tmdbLogoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); - - if (tmdbLogoUrl) { - logger.log(`[MetadataScreen] Successfully fetched logo from TMDB: ${tmdbLogoUrl}`); - } else { - logger.warn(`[MetadataScreen] No logo found from TMDB for ${type} (ID: ${tmdbId})`); - } - } catch (error) { - logger.error(`[MetadataScreen] Error fetching TMDB logo for ID ${tmdbId}:`, error); - } - } else if (imdbId) { - // If we have IMDB ID but no direct TMDB ID, try to find TMDB ID - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - logger.log(`[MetadataScreen] Content has IMDB ID (${imdbId}), looking up TMDB ID for TMDB logo, preferred language: ${preferredLanguage}`); - try { - const tmdbService = TMDBService.getInstance(); - const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); - - if (foundTmdbId) { - logger.log(`[MetadataScreen] Found TMDB ID ${foundTmdbId} for IMDB ID ${imdbId}`); - setFoundTmdbId(String(foundTmdbId)); // Save for banner fetching - - tmdbLogoUrl = await tmdbService.getContentLogo(type === 'series' ? 'tv' : 'movie', foundTmdbId.toString(), preferredLanguage); - - if (tmdbLogoUrl) { - logger.log(`[MetadataScreen] Successfully fetched logo from TMDB via IMDB lookup: ${tmdbLogoUrl}`); - } else { - logger.warn(`[MetadataScreen] No logo found from TMDB via IMDB lookup for ${type} (IMDB: ${imdbId})`); - } - } else { - logger.warn(`[MetadataScreen] Could not find TMDB ID for IMDB ID ${imdbId}`); - } - } catch (error) { - logger.error(`[MetadataScreen] Error finding TMDB ID or fetching logo for IMDB ID ${imdbId}:`, error); - } - } - - // 2. If TMDB logo was fetched successfully, update and return - if (tmdbLogoUrl) { - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: tmdbLogoUrl - })); - logoFetchInProgress.current = false; - return; - } - - // 3. If TMDB failed, try Metahub as fallback - logger.log(`[MetadataScreen] TMDB logo fetch failed or not applicable. Attempting Metahub fallback.`); - if (imdbId) { - const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub as fallback for ${imdbId}`); - - try { - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - logger.log(`[MetadataScreen] Successfully fetched fallback logo from Metahub: ${metahubUrl}`); - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: metahubUrl })); - } else { - logger.warn(`[MetadataScreen] Metahub fallback failed. Using title text.`); - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); - } - } catch (metahubError) { - logger.warn(`[MetadataScreen] Failed to fetch fallback logo from Metahub:`, metahubError); - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); - } - } else { - // No IMDB ID for Metahub fallback - logger.warn(`[MetadataScreen] No IMDB ID for Metahub fallback. Using title text.`); - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); - } - } - } catch (error) { - logger.error('[MetadataScreen] Failed to fetch logo from all sources:', { - error, - contentId: id, - contentType: type - }); - // Fallback to text on general error - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); - } finally { - // Clear fetch in progress flag when done - logoFetchInProgress.current = false; - } - }; - - fetchLogo(); - } else if (logoFetchInProgress.current) { - console.log('[MetadataScreen] Logo fetch already in progress, skipping'); - } else if (metadata?.logo) { - logger.log(`[MetadataScreen] Using existing logo from metadata: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metadata.logo} - - Source: ${isMetahubUrl(metadata.logo) ? 'Metahub' : (isTmdbUrl(metadata.logo) ? 'TMDB' : 'Other')} - `); - } - }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference]); - - // Function to get episode details from episodeId - const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => { - // Try to parse from format "seriesId:season:episode" - const parts = episodeId.split(':'); - if (parts.length === 3) { - const [, seasonNum, episodeNum] = parts; - // Find episode in our local episodes array - const episode = episodes.find( - ep => ep.season_number === parseInt(seasonNum) && - ep.episode_number === parseInt(episodeNum) - ); - - if (episode) { - return { - seasonNumber: seasonNum, - episodeNumber: episodeNum, - episodeName: episode.name - }; - } - } - - // If not found by season/episode, try stremioId - const episodeByStremioId = episodes.find(ep => ep.stremioId === episodeId); - if (episodeByStremioId) { - return { - seasonNumber: episodeByStremioId.season_number.toString(), - episodeNumber: episodeByStremioId.episode_number.toString(), - episodeName: episodeByStremioId.name - }; - } - - return null; - }, [episodes]); - - const loadWatchProgress = useCallback(async () => { - try { - if (id && type) { - if (type === 'series') { - const allProgress = await storageService.getAllWatchProgress(); - - // Function to get episode number from episodeId - const getEpisodeNumber = (epId: string) => { - const parts = epId.split(':'); - if (parts.length === 3) { - return { - season: parseInt(parts[1]), - episode: parseInt(parts[2]) - }; - } - return null; - }; - - // Get all episodes for this series with progress - const seriesProgresses = Object.entries(allProgress) - .filter(([key]) => key.includes(`${type}:${id}:`)) - .map(([key, value]) => ({ - episodeId: key.split(`${type}:${id}:`)[1], - progress: value - })) - .filter(({ episodeId, progress }) => { - const progressPercent = (progress.currentTime / progress.duration) * 100; - return progressPercent > 0; - }); - - // If we have a specific episodeId in route params - if (episodeId) { - const progress = await storageService.getWatchProgress(id, type, episodeId); - if (progress) { - const progressPercent = (progress.currentTime / progress.duration) * 100; - - // If current episode is finished (≥95%), try to find next unwatched episode - if (progressPercent >= 95) { - const currentEpNum = getEpisodeNumber(episodeId); - if (currentEpNum && episodes.length > 0) { - // Find the next episode - const nextEpisode = episodes.find(ep => { - // First check in same season - if (ep.season_number === currentEpNum.season && ep.episode_number > currentEpNum.episode) { - const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; - const epProgress = seriesProgresses.find(p => p.episodeId === epId); - if (!epProgress) return true; - const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; - return percent < 95; - } - // Then check next seasons - if (ep.season_number > currentEpNum.season) { - const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; - const epProgress = seriesProgresses.find(p => p.episodeId === epId); - if (!epProgress) return true; - const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; - return percent < 95; - } - return false; - }); - - if (nextEpisode) { - const nextEpisodeId = nextEpisode.stremioId || - `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; - const nextProgress = await storageService.getWatchProgress(id, type, nextEpisodeId); - if (nextProgress) { - setWatchProgress({ ...nextProgress, episodeId: nextEpisodeId }); - } else { - setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: nextEpisodeId }); - } - return; - } - } - // If no next episode found or current episode is finished, show no progress - setWatchProgress(null); - return; - } - - // If current episode is not finished, show its progress - setWatchProgress({ ...progress, episodeId }); - } else { - setWatchProgress(null); - } - } else { - // Find the first unfinished episode - const unfinishedEpisode = episodes.find(ep => { - const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; - const progress = seriesProgresses.find(p => p.episodeId === epId); - if (!progress) return true; - const percent = (progress.progress.currentTime / progress.progress.duration) * 100; - return percent < 95; - }); - - if (unfinishedEpisode) { - const epId = unfinishedEpisode.stremioId || - `${id}:${unfinishedEpisode.season_number}:${unfinishedEpisode.episode_number}`; - const progress = await storageService.getWatchProgress(id, type, epId); - if (progress) { - setWatchProgress({ ...progress, episodeId: epId }); - } else { - setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: epId }); - } - } else { - setWatchProgress(null); - } - } - } else { - // For movies - const progress = await storageService.getWatchProgress(id, type, episodeId); - if (progress && progress.currentTime > 0) { - const progressPercent = (progress.currentTime / progress.duration) * 100; - if (progressPercent >= 95) { - setWatchProgress(null); - } else { - setWatchProgress({ ...progress, episodeId }); - } - } else { - setWatchProgress(null); - } - } - } - } catch (error) { - logger.error('[MetadataScreen] Error loading watch progress:', error); - setWatchProgress(null); - } - }, [id, type, episodeId, episodes, getEpisodeDetails]); - - // Initial load - useEffect(() => { - loadWatchProgress(); - }, [loadWatchProgress]); - - // Refresh when screen comes into focus - useFocusEffect( - useCallback(() => { - loadWatchProgress(); - }, [loadWatchProgress]) - ); - - // Function to get play button text - const getPlayButtonText = useCallback(() => { - if (!watchProgress || watchProgress.currentTime <= 0) { - return 'Play'; - } - - // Consider episode complete if progress is >= 95% - const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; - if (progressPercent >= 95) { - return 'Play'; - } - - return 'Resume'; - }, [watchProgress]); - - // Add effect to animate watch progress when it changes - useEffect(() => { - if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withSpring(1, { - mass: 0.2, - stiffness: 100, - damping: 14 - }); - watchProgressScaleY.value = withSpring(1, { - mass: 0.3, - stiffness: 120, - damping: 18 - }); - } else { - watchProgressOpacity.value = withSpring(0, { - mass: 0.2, - stiffness: 100, - damping: 14 - }); - watchProgressScaleY.value = withSpring(0, { - mass: 0.3, - stiffness: 120, - damping: 18 - }); - } - }, [watchProgress, watchProgressOpacity, watchProgressScaleY]); - - // Add animated style for watch progress - const watchProgressAnimatedStyle = useAnimatedStyle(() => { - const translateY = interpolate( - watchProgressScaleY.value, - [0, 1], - [-8, 0], - Extrapolate.CLAMP - ); - - return { - opacity: watchProgressOpacity.value, - transform: [ - { translateY: translateY }, - { scaleY: watchProgressScaleY.value } - ] - }; - }); - - // Add animated style for logo - const logoAnimatedStyle = useAnimatedStyle(() => { - return { - opacity: logoOpacity.value, - transform: [{ scale: logoScale.value }] - }; - }); - - // Effect to animate logo when it's available - useEffect(() => { - if (metadata?.logo) { - logoOpacity.value = withTiming(1, { - duration: 500, - easing: Easing.out(Easing.ease) - }); - } else { - logoOpacity.value = withTiming(0, { - duration: 200, - easing: Easing.in(Easing.ease) - }); - } - }, [metadata?.logo, logoOpacity]); - - // Update the watch progress render function - Now uses WatchProgressDisplay component - // const renderWatchProgress = () => { ... }; // Removed old inline function - // Handler functions const handleShowStreams = useCallback(() => { if (type === 'series') { @@ -1109,13 +146,10 @@ const MetadataScreen = () => { }, [navigation, id, type, episodes, episodeId, watchProgress]); const handleSelectCastMember = useCallback((castMember: any) => { - // Potentially navigate to a cast member screen or show details - logger.log('Cast member selected:', castMember); - }, []); // Empty dependency array as it doesn't depend on component state/props currently + // Future implementation + }, []); const handleEpisodeSelect = useCallback((episode: Episode) => { - // Removed haptic feedback - const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; navigation.navigate('Streams', { id, @@ -1124,286 +158,27 @@ const MetadataScreen = () => { }); }, [navigation, id, type]); + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + // Animated styles const containerAnimatedStyle = useAnimatedStyle(() => ({ flex: 1, - transform: [{ scale: screenScale.value }], - opacity: screenOpacity.value + transform: [{ scale: animations.screenScale.value }], + opacity: animations.screenOpacity.value })); const contentAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: contentTranslateY.value }], + transform: [{ translateY: animations.contentTranslateY.value }], opacity: interpolate( - contentTranslateY.value, + animations.contentTranslateY.value, [60, 0], [0, 1], Extrapolate.CLAMP ) })); - // Add animated style for genres - const genresAnimatedStyle = useAnimatedStyle(() => { - return { - opacity: genresOpacity.value, - transform: [{ translateY: genresTranslateY.value }] - }; - }); - - // Add animated style for buttons - const buttonsAnimatedStyle = useAnimatedStyle(() => { - return { - opacity: buttonsOpacity.value, - transform: [{ translateY: buttonsTranslateY.value }] - }; - }); - - // Debug logs for director/creator data - React.useEffect(() => { - if (metadata && metadata.id) { - const fetchCrewData = async () => { - try { - const tmdb = TMDBService.getInstance(); - const tmdbId = await tmdb.extractTMDBIdFromStremioId(id); - - if (tmdbId) { - const credits = await tmdb.getCredits(tmdbId, type); - // logger.log("Credits data structure:", JSON.stringify(credits).substring(0, 300)); - - // Extract directors for movies - if (type === 'movie' && credits.crew) { - const directors = credits.crew - .filter((person: { job: string }) => person.job === 'Director') - .map((director: { name: string }) => director.name); - - if (directors.length > 0 && metadata) { - // Update metadata with directors - setMetadata({ - ...metadata, - directors - }); - // logger.log("Updated directors:", directors); - } - } - - // Extract creators for TV shows - if (type === 'series' && credits.crew) { - const creators = credits.crew - .filter((person: { job?: string; department?: string }) => - person.job === 'Creator' || - person.job === 'Series Creator' || - person.department === 'Production' || - person.job === 'Executive Producer' - ) - .map((creator: { name: string }) => creator.name); - - if (creators.length > 0 && metadata) { - // Update metadata with creators - setMetadata({ - ...metadata, - creators: creators.slice(0, 3) // Limit to first 3 creators - }); - // logger.log("Updated creators:", creators.slice(0, 3)); - } - } - } - } catch (error) { - logger.error('Error fetching crew data:', error); - } - }; - - fetchCrewData(); - } - }, [metadata?.id, id, type, setMetadata]); - - // Start entrance animation - React.useEffect(() => { - // Use a timeout to ensure the animations starts after the component is mounted - const animationTimeout = setTimeout(() => { - // 1. First animate the container - screenScale.value = withSpring(1, springConfig); - screenOpacity.value = withSpring(1, springConfig); - - // 2. Then animate the hero section with a slight delay - setTimeout(() => { - heroOpacity.value = withSpring(1, { - damping: 14, - stiffness: 80 - }); - heroScale.value = withSpring(1, { - damping: 18, - stiffness: 100 - }); - }, ANIMATION_DELAY_CONSTANTS.HERO); - - // 3. Then animate the logo - setTimeout(() => { - logoOpacity.value = withSpring(1, { - damping: 12, - stiffness: 100 - }); - logoScale.value = withSpring(1, { - damping: 14, - stiffness: 90 - }); - }, ANIMATION_DELAY_CONSTANTS.LOGO); - - // 4. Then animate the watch progress if applicable - setTimeout(() => { - if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 - }); - watchProgressScaleY.value = withSpring(1, { - damping: 18, - stiffness: 120 - }); - } - }, ANIMATION_DELAY_CONSTANTS.PROGRESS); - - // 5. Then animate the genres - setTimeout(() => { - genresOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 - }); - genresTranslateY.value = withSpring(0, { - damping: 18, - stiffness: 120 - }); - }, ANIMATION_DELAY_CONSTANTS.GENRES); - - // 6. Then animate the buttons - setTimeout(() => { - buttonsOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 - }); - buttonsTranslateY.value = withSpring(0, { - damping: 18, - stiffness: 120 - }); - }, ANIMATION_DELAY_CONSTANTS.BUTTONS); - - // 7. Finally animate the content section - setTimeout(() => { - contentTranslateY.value = withSpring(0, { - damping: 25, - mass: 1, - stiffness: 100 - }); - }, ANIMATION_DELAY_CONSTANTS.CONTENT); - }, 50); // Small timeout to ensure component is fully mounted - - return () => clearTimeout(animationTimeout); - }, []); - - const handleBack = useCallback(() => { - // Use goBack() which will return to the previous screen in the navigation stack - // This will work for both cases: - // 1. Coming from Calendar/ThisWeek - goes back to them - // 2. Coming from StreamsScreen - goes back to Calendar/ThisWeek - navigation.goBack(); - }, [navigation]); - - // Function to render genres (updated to handle string array and use useMemo) - const renderGenres = useMemo(() => { - if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) { - return null; - } - - // Since metadata.genres is string[], we display them directly - const genresToDisplay: string[] = metadata.genres as string[]; - - return genresToDisplay.slice(0, 4).map((genreName, index, array) => ( - // Use React.Fragment to avoid extra View wrappers - - {genreName} - {/* Add dot separator */} - {index < array.length - 1 && ( - - )} - - )); - }, [metadata?.genres]); // Dependency on metadata.genres - - // Update the heroAnimatedStyle for parallax effect - const heroAnimatedStyle = useAnimatedStyle(() => ({ - width: '100%', - height: heroHeight.value, - backgroundColor: colors.black, - transform: [{ scale: heroScale.value }], - opacity: heroOpacity.value, - })); - - // Replace direct onScroll with useAnimatedScrollHandler - const scrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - const rawScrollY = event.contentOffset.y; - scrollY.value = rawScrollY; - - // Apply spring-like damping for smoother transitions - dampedScrollY.value = withTiming(rawScrollY, { - duration: 300, - easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve - }); - - // Update header opacity based on scroll position - const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer - if (rawScrollY > headerThreshold) { - headerOpacity.value = withTiming(1, { duration: 200 }); - headerElementsY.value = withTiming(0, { duration: 300 }); - headerElementsOpacity.value = withTiming(1, { duration: 450 }); - } else { - headerOpacity.value = withTiming(0, { duration: 150 }); - headerElementsY.value = withTiming(-10, { duration: 200 }); - headerElementsOpacity.value = withTiming(0, { duration: 200 }); - } - }, - }); - - // Add a new animated style for the parallax image - const parallaxImageStyle = useAnimatedStyle(() => { - // Use dampedScrollY instead of direct scrollY for smoother effect - return { - width: '100%', - height: '120%', // Increase height for more movement range - top: '-10%', // Start image slightly higher to allow more upward movement - transform: [ - { - translateY: interpolate( - dampedScrollY.value, - [0, 100, 300], - [20, -20, -60], // Start with a lower position, then move up - Extrapolate.CLAMP - ) - }, - { - scale: interpolate( - dampedScrollY.value, - [0, 150, 300], - [1.1, 1.02, 0.95], // More dramatic scale changes - Extrapolate.CLAMP - ) - } - ], - }; - }); - - // Add animated style for floating header - const headerAnimatedStyle = useAnimatedStyle(() => ({ - opacity: headerOpacity.value, - transform: [ - { translateY: interpolate(headerOpacity.value, [0, 1], [-20, 0], Extrapolate.CLAMP) } - ] - })); - - // Add animated style for header elements - const headerElementsStyle = useAnimatedStyle(() => ({ - opacity: headerElementsOpacity.value, - transform: [{ translateY: headerElementsY.value }] - })); - if (loading) { return ( { /> {/* Floating Header */} - - {Platform.OS === 'ios' ? ( - - - - - - - - {metadata.logo && !logoLoadError ? ( - { - logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); - setLogoLoadError(true); - }} - /> - ) : ( - {metadata.name} - )} - - - - - - - - ) : ( - - - - - - - - - {metadata.logo && !logoLoadError ? ( - { - logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); - setLogoLoadError(true); - }} - /> - ) : ( - {metadata.name} - )} - - - - - - - - )} - {Platform.OS === 'ios' && } - + {/* Hero Section */} - - - {/* Use Animated.Image directly instead of ImageBackground with imageStyle */} - {loadingBanner ? ( - - ) : ( - { - logger.warn(`[MetadataScreen] Banner failed to load: ${bannerImage}`); - // If custom banner fails, fall back to original metadata banner - if (bannerImage !== metadata.banner) { - setBannerImage(metadata.banner || metadata.poster); - } - }} - /> - )} - - - {/* Title */} - - - {metadata.logo && !logoLoadError ? ( - { - logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); - setLogoLoadError(true); - }} - /> - ) : ( - {metadata.name} - )} - - - - {/* Watch Progress */} - - - {/* Genre Tags */} - - - {renderGenres} - - - - {/* Action Buttons */} - - - - - {/* Main Content */} - {/* Meta Info */} - - {metadata.year && ( - {metadata.year} - )} - {metadata.runtime && ( - {metadata.runtime} - )} - {metadata.certification && ( - {metadata.certification} - )} - {metadata.imdbRating && ( - - - {metadata.imdbRating} - - )} - + {/* Metadata Details */} + {/* Add RatingsSection right under the main metadata */} {imdbId && ( @@ -1710,52 +332,6 @@ const MetadataScreen = () => { /> )} - {/* Creator/Director Info */} - - {metadata.directors && metadata.directors.length > 0 && ( - - Director{metadata.directors.length > 1 ? 's' : ''}: - {metadata.directors.join(', ')} - - )} - {metadata.creators && metadata.creators.length > 0 && ( - - Creator{metadata.creators.length > 1 ? 's' : ''}: - {metadata.creators.join(', ')} - - )} - - - {/* Description */} - {metadata.description && ( - - setIsFullDescriptionOpen(!isFullDescriptionOpen)} - activeOpacity={0.7} - > - - {metadata.description} - - - - {isFullDescriptionOpen ? 'Show Less' : 'Show More'} - - - - - - )} - {/* Cast Section */}