Refactor MetadataScreen to enhance modularity and integrate new components

This update significantly refactors the MetadataScreen component by removing unused imports and consolidating logic into dedicated hooks and components. The new structure improves readability and maintainability, while also enhancing the user experience with a more cohesive layout. Key changes include the introduction of HeroSection, FloatingHeader, and MetadataDetails components, which streamline the rendering process and improve the overall UI consistency. Additionally, the integration of new hooks for managing metadata assets and animations optimizes performance and responsiveness.
This commit is contained in:
tapframe 2025-05-03 22:25:42 +05:30
parent 12379b9e34
commit 10cbf077d6
7 changed files with 1992 additions and 1794 deletions

View file

@ -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<number>;
headerElementsY: Animated.SharedValue<number>;
headerElementsOpacity: Animated.SharedValue<number>;
safeAreaTop: number;
setLogoLoadError: (error: boolean) => void;
}
const FloatingHeader: React.FC<FloatingHeaderProps> = ({
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 (
<Animated.View style={[styles.floatingHeader, headerAnimatedStyle]}>
{Platform.OS === 'ios' ? (
<ExpoBlurView
intensity={50}
tint="dark"
style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}
>
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons name="arrow-back" size={24} color={colors.highEmphasis} />
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
{metadata.logo && !logoLoadError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo}
contentFit="contain"
transition={150}
onError={() => {
logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/>
) : (
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
)}
</View>
<TouchableOpacity
style={styles.headerActionButton}
onPress={handleToggleLibrary}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={22}
color={colors.highEmphasis}
/>
</TouchableOpacity>
</Animated.View>
</ExpoBlurView>
) : (
<View style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}>
<CommunityBlurView
style={styles.absoluteFill}
blurType="dark"
blurAmount={15}
reducedTransparencyFallbackColor="rgba(20, 20, 20, 0.9)"
/>
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons name="arrow-back" size={24} color={colors.highEmphasis} />
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
{metadata.logo && !logoLoadError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo}
contentFit="contain"
transition={150}
onError={() => {
logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/>
) : (
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
)}
</View>
<TouchableOpacity
style={styles.headerActionButton}
onPress={handleToggleLibrary}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={22}
color={colors.highEmphasis}
/>
</TouchableOpacity>
</Animated.View>
</View>
)}
{Platform.OS === 'ios' && <View style={styles.headerBottomBorder} />}
</Animated.View>
);
};
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);

View file

@ -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<number>;
dampedScrollY: Animated.SharedValue<number>;
heroHeight: Animated.SharedValue<number>;
heroOpacity: Animated.SharedValue<number>;
heroScale: Animated.SharedValue<number>;
logoOpacity: Animated.SharedValue<number>;
logoScale: Animated.SharedValue<number>;
genresOpacity: Animated.SharedValue<number>;
genresTranslateY: Animated.SharedValue<number>;
buttonsOpacity: Animated.SharedValue<number>;
buttonsTranslateY: Animated.SharedValue<number>;
watchProgressOpacity: Animated.SharedValue<number>;
watchProgressScaleY: Animated.SharedValue<number>;
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 (
<Animated.View style={[styles.actionButtons, animatedStyle]}>
<TouchableOpacity
style={[styles.actionButton, styles.playButton]}
onPress={handleShowStreams}
>
<MaterialIcons
name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"}
size={24}
color="#000"
/>
<Text style={styles.playButtonText}>
{playButtonText}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.infoButton]}
onPress={toggleLibrary}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={24}
color="#fff"
/>
<Text style={styles.infoButtonText}>
{inLibrary ? 'Saved' : 'Save'}
</Text>
</TouchableOpacity>
{type === 'series' && (
<TouchableOpacity
style={[styles.iconButton]}
onPress={async () => {
navigation.navigate('ShowRatings', { showId: id.split(':')[1] });
}}
>
<MaterialIcons name="assessment" size={24} color="#fff" />
</TouchableOpacity>
)}
</Animated.View>
);
});
// 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 (
<Animated.View style={[styles.watchProgressContainer, animatedStyle]}>
<View style={styles.watchProgressBar}>
<View
style={[
styles.watchProgressFill,
{ width: `${progressPercent}%` }
]}
/>
</View>
<Text style={styles.watchProgressText}>
{progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} Last watched on {formattedTime}
</Text>
</Animated.View>
);
});
const HeroSection: React.FC<HeroSectionProps> = ({
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) => (
<React.Fragment key={index}>
<Text style={styles.genreText}>{genreName}</Text>
{index < array.length - 1 && (
<Text style={styles.genreDot}></Text>
)}
</React.Fragment>
));
};
return (
<Animated.View style={heroAnimatedStyle}>
<View style={styles.heroSection}>
{loadingBanner ? (
<View style={[styles.absoluteFill, { backgroundColor: colors.black }]} />
) : (
<Animated.Image
source={{ uri: bannerImage || metadata.banner || metadata.poster }}
style={[styles.absoluteFill, parallaxImageStyle]}
resizeMode="cover"
onError={() => {
logger.warn(`[HeroSection] Banner failed to load: ${bannerImage}`);
if (bannerImage !== metadata.banner) {
setBannerImage(metadata.banner || metadata.poster);
}
}}
/>
)}
<LinearGradient
colors={[
`${colors.darkBackground}00`,
`${colors.darkBackground}20`,
`${colors.darkBackground}50`,
`${colors.darkBackground}C0`,
`${colors.darkBackground}F8`,
colors.darkBackground
]}
locations={[0, 0.4, 0.65, 0.8, 0.9, 1]}
style={styles.heroGradient}
>
<View style={styles.heroContent}>
{/* Title/Logo */}
<View style={styles.logoContainer}>
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
{metadata.logo && !logoLoadError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.titleLogo}
contentFit="contain"
transition={300}
onError={() => {
logger.warn(`[HeroSection] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/>
) : (
<Text style={styles.heroTitle}>{metadata.name}</Text>
)}
</Animated.View>
</View>
{/* Watch Progress */}
<WatchProgressDisplay
watchProgress={watchProgress}
type={type}
getEpisodeDetails={getEpisodeDetails}
animatedStyle={watchProgressAnimatedStyle}
/>
{/* Genre Tags */}
<Animated.View style={genresAnimatedStyle}>
<View style={styles.genreContainer}>
{renderGenres()}
</View>
</Animated.View>
{/* Action Buttons */}
<ActionButtons
handleShowStreams={handleShowStreams}
toggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
type={type}
id={id}
navigation={navigation}
playButtonText={getPlayButtonText()}
animatedStyle={buttonsAnimatedStyle}
/>
</View>
</LinearGradient>
</View>
</Animated.View>
);
};
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);

View file

@ -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<MetadataDetailsProps> = ({
metadata,
imdbId,
type,
}) => {
const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false);
return (
<>
{/* Meta Info */}
<View style={styles.metaInfo}>
{metadata.year && (
<Text style={styles.metaText}>{metadata.year}</Text>
)}
{metadata.runtime && (
<Text style={styles.metaText}>{metadata.runtime}</Text>
)}
{metadata.certification && (
<Text style={styles.metaText}>{metadata.certification}</Text>
)}
{metadata.imdbRating && (
<View style={styles.ratingContainer}>
<Image
source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png' }}
style={styles.imdbLogo}
contentFit="contain"
/>
<Text style={styles.ratingText}>{metadata.imdbRating}</Text>
</View>
)}
</View>
{/* Creator/Director Info */}
<Animated.View
entering={FadeIn.duration(500).delay(200)}
style={styles.creatorContainer}
>
{metadata.directors && metadata.directors.length > 0 && (
<View style={styles.creatorSection}>
<Text style={styles.creatorLabel}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text>
<Text style={styles.creatorText}>{metadata.directors.join(', ')}</Text>
</View>
)}
{metadata.creators && metadata.creators.length > 0 && (
<View style={styles.creatorSection}>
<Text style={styles.creatorLabel}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text>
<Text style={styles.creatorText}>{metadata.creators.join(', ')}</Text>
</View>
)}
</Animated.View>
{/* Description */}
{metadata.description && (
<Animated.View
style={styles.descriptionContainer}
layout={Layout.duration(300).easing(Easing.inOut(Easing.ease))}
>
<TouchableOpacity
onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)}
activeOpacity={0.7}
>
<Text style={styles.description} numberOfLines={isFullDescriptionOpen ? undefined : 3}>
{metadata.description}
</Text>
<View style={styles.showMoreButton}>
<Text style={styles.showMoreText}>
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
</Text>
<MaterialIcons
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={18}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
</Animated.View>
)}
</>
);
};
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);

View file

@ -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,
};
};

View file

@ -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<string | null>(null);
const [loadingBanner, setLoadingBanner] = useState<boolean>(false);
const forcedBannerRefreshDone = useRef<boolean>(false);
// State for logo loading
const [logoLoadError, setLogoLoadError] = useState(false);
const logoFetchInProgress = useRef<boolean>(false);
const logoRefreshCounter = useRef<number>(0);
const MAX_LOGO_REFRESHES = 2;
const forcedLogoRefreshDone = useRef<boolean>(false);
// For TMDB ID tracking
const [foundTmdbId, setFoundTmdbId] = useState<string | null>(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,
};
};

View file

@ -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<WatchProgressData | null>(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
};
};

File diff suppressed because it is too large Load diff