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