Enhance logo loading and error handling in FeaturedContent, MetadataScreen, and SearchScreen components. Introduce state management for logo load errors, improve image prefetching logic, and update UI to fallback to text when logos fail to load. Refactor TMDBService to include detailed logging for image URL construction and fetching processes.

This commit is contained in:
tapframe 2025-05-03 17:11:16 +05:30
parent 5e81a14ebb
commit 5a64adec22
5 changed files with 663 additions and 145 deletions

View file

@ -28,6 +28,7 @@ import Animated, {
} from 'react-native-reanimated';
import { StreamingContent } from '../../services/catalogService';
import { SkeletonFeatured } from './SkeletonLoaders';
import { isValidMetahubLogo, hasValidLogoFormat } from '../../utils/logoUtils';
interface FeaturedContentProps {
featuredContent: StreamingContent | null;
@ -45,6 +46,8 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
const prevContentIdRef = useRef<string | null>(null);
// Add state for tracking logo load errors
const [logoLoadError, setLogoLoadError] = useState(false);
// Animation values
const posterOpacity = useSharedValue(0);
@ -74,15 +77,37 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
if (imageCache[url]) return true;
try {
// For Metahub logos, only do validation if enabled
// Note: Temporarily disable metahub validation until fixed
if (false && url.includes('metahub.space')) {
try {
const isValid = await isValidMetahubLogo(url);
if (!isValid) {
console.warn(`[FeaturedContent] Metahub logo validation failed: ${url}`);
return false;
}
} catch (validationError) {
// If validation fails, still try to load the image
console.warn(`[FeaturedContent] Logo validation error, will try to load anyway: ${url}`, validationError);
}
}
// Always attempt to prefetch the image regardless of format validation
await ExpoImage.prefetch(url);
imageCache[url] = true;
console.log(`[FeaturedContent] Successfully preloaded image: ${url}`);
return true;
} catch (error) {
console.error('Error preloading image:', error);
console.error('[FeaturedContent] Error preloading image:', error);
return false;
}
};
// Reset logo error state when content changes
useEffect(() => {
setLogoLoadError(false);
}, [featuredContent?.id]);
// Load poster and logo
useEffect(() => {
if (!featuredContent) return;
@ -124,6 +149,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
duration: 500,
easing: Easing.bezier(0.25, 0.1, 0.25, 1)
}));
} else {
// If prefetch fails, mark as error to show title text instead
setLogoLoadError(true);
console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${titleLogo}`);
}
}
};
@ -165,7 +194,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
<Animated.View
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
>
{featuredContent.logo ? (
{featuredContent.logo && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}>
<ExpoImage
source={{ uri: logoUrl || featuredContent.logo }}
@ -173,6 +202,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
contentFit="contain"
cachePolicy="memory-disk"
transition={400}
onError={() => {
console.warn(`[FeaturedContent] Logo failed to load: ${featuredContent.logo}`);
setLogoLoadError(true);
}}
/>
</Animated.View>
) : (

View file

@ -56,6 +56,7 @@ import { TMDBService } from '../services/tmdbService';
import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
import { useGenres } from '../contexts/GenreContext';
import { isValidMetahubLogo, isMetahubUrl, isTmdbUrl } from '../utils/logoUtils';
const { width, height } = Dimensions.get('window');
@ -264,6 +265,14 @@ const MetadataScreen = () => {
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 wrapper for toggleLibrary that includes haptic feedback
const handleToggleLibrary = useCallback(() => {
// Trigger appropriate haptic feedback based on action
@ -324,7 +333,7 @@ const MetadataScreen = () => {
logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub for ${imdbId}`);
// Test if Metahub logo exists with a HEAD request
// For now, skip detailed validation and just check if URL is accessible
try {
const response = await fetch(metahubUrl, { method: 'HEAD' });
if (response.ok) {
@ -340,6 +349,8 @@ const MetadataScreen = () => {
logo: metahubUrl
}));
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);
@ -367,8 +378,16 @@ const MetadataScreen = () => {
logo: logoUrl
}));
} else {
logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id})`);
// 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 {
// If no TMDB ID and Metahub failed, use the title as text instead of a logo
logger.warn(`[MetadataScreen] No logo found for ${type} (ID: ${id}), using title text instead`);
// Leave logo as null/undefined to trigger fallback to text
}
} catch (error) {
logger.error('[MetadataScreen] Failed to fetch logo from all sources:', {
@ -385,6 +404,7 @@ const MetadataScreen = () => {
- 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]);
@ -1077,12 +1097,16 @@ const MetadataScreen = () => {
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
{metadata.logo ? (
{metadata.logo && !logoLoadError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo}
contentFit="contain"
transition={150}
onError={() => {
logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/>
) : (
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
@ -1120,12 +1144,16 @@ const MetadataScreen = () => {
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
{metadata.logo ? (
{metadata.logo && !logoLoadError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo}
contentFit="contain"
transition={150}
onError={() => {
logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/>
) : (
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
@ -1181,12 +1209,16 @@ const MetadataScreen = () => {
{/* Title */}
<View style={styles.logoContainer}>
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
{metadata.logo ? (
{metadata.logo && !logoLoadError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.titleLogo}
contentFit="contain"
transition={300}
onError={() => {
logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/>
) : (
<Text style={styles.heroTitle}>{metadata.name}</Text>

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
Text,
@ -14,6 +14,9 @@ import {
Dimensions,
ScrollView,
Animated as RNAnimated,
Pressable,
Platform,
Easing,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
@ -23,9 +26,22 @@ import { catalogService, StreamingContent } from '../services/catalogService';
import { Image } from 'expo-image';
import debounce from 'lodash/debounce';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Animated, { FadeIn, FadeOut, SlideInRight } from 'react-native-reanimated';
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
useAnimatedStyle,
useSharedValue,
withTiming,
interpolate,
withSpring,
withDelay,
ZoomIn
} from 'react-native-reanimated';
import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger';
import { BlurView } from 'expo-blur';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width } = Dimensions.get('window');
const HORIZONTAL_ITEM_WIDTH = width * 0.3;
@ -37,6 +53,8 @@ const MAX_RECENT_SEARCHES = 10;
const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster';
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
const SkeletonLoader = () => {
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current;
@ -91,6 +109,72 @@ const SkeletonLoader = () => {
);
};
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Create a simple, elegant animation component
const SimpleSearchAnimation = () => {
// Simple animation values that work reliably
const spinAnim = React.useRef(new RNAnimated.Value(0)).current;
const fadeAnim = React.useRef(new RNAnimated.Value(0)).current;
React.useEffect(() => {
// Rotation animation
const spin = RNAnimated.loop(
RNAnimated.timing(spinAnim, {
toValue: 1,
duration: 1500,
easing: Easing.linear,
useNativeDriver: true,
})
);
// Fade animation
const fade = RNAnimated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
});
// Start animations
spin.start();
fade.start();
// Clean up
return () => {
spin.stop();
};
}, [spinAnim, fadeAnim]);
// Simple rotation interpolation
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
return (
<RNAnimated.View
style={[
styles.simpleAnimationContainer,
{ opacity: fadeAnim }
]}
>
<View style={styles.simpleAnimationContent}>
<RNAnimated.View style={[
styles.spinnerContainer,
{ transform: [{ rotate: spin }] }
]}>
<MaterialIcons
name="search"
size={32}
color={colors.white}
/>
</RNAnimated.View>
<Text style={styles.simpleAnimationText}>Searching</Text>
</View>
</RNAnimated.View>
);
};
const SearchScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = true;
@ -100,6 +184,30 @@ const SearchScreen = () => {
const [searched, setSearched] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [showRecent, setShowRecent] = useState(true);
const inputRef = useRef<TextInput>(null);
const insets = useSafeAreaInsets();
// Animation values
const searchBarWidth = useSharedValue(width - 32);
const searchBarOpacity = useSharedValue(1);
const backButtonOpacity = useSharedValue(0);
// Force consistent status bar settings
useEffect(() => {
const applyStatusBarConfig = () => {
StatusBar.setBarStyle('light-content');
if (Platform.OS === 'android') {
StatusBar.setTranslucent(true);
StatusBar.setBackgroundColor('transparent');
}
};
applyStatusBarConfig();
// Re-apply on focus
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
return unsubscribe;
}, [navigation]);
React.useLayoutEffect(() => {
navigation.setOptions({
@ -111,6 +219,55 @@ const SearchScreen = () => {
loadRecentSearches();
}, []);
const animatedSearchBarStyle = useAnimatedStyle(() => {
return {
width: searchBarWidth.value,
opacity: searchBarOpacity.value,
};
});
const animatedBackButtonStyle = useAnimatedStyle(() => {
return {
opacity: backButtonOpacity.value,
transform: [
{
translateX: interpolate(
backButtonOpacity.value,
[0, 1],
[-20, 0]
)
}
]
};
});
const handleSearchFocus = () => {
// Animate search bar when focused
searchBarWidth.value = withTiming(width - 80);
backButtonOpacity.value = withTiming(1);
};
const handleSearchBlur = () => {
if (!query) {
// Only animate back if query is empty
searchBarWidth.value = withTiming(width - 32);
backButtonOpacity.value = withTiming(0);
}
};
const handleBackPress = () => {
Keyboard.dismiss();
if (query) {
setQuery('');
setResults([]);
setSearched(false);
setShowRecent(true);
loadRecentSearches();
} else {
navigation.goBack();
}
};
const loadRecentSearches = async () => {
try {
const savedSearches = await AsyncStorage.getItem(RECENT_SEARCHES_KEY);
@ -147,7 +304,9 @@ const SearchScreen = () => {
try {
const searchResults = await catalogService.searchContentCinemeta(searchQuery);
setResults(searchResults);
await saveRecentSearch(searchQuery);
if (searchResults.length > 0) {
await saveRecentSearch(searchQuery);
}
} catch (error) {
logger.error('Search failed:', error);
setResults([]);
@ -178,50 +337,66 @@ const SearchScreen = () => {
setSearched(false);
setShowRecent(true);
loadRecentSearches();
inputRef.current?.focus();
};
const renderRecentSearches = () => {
if (!showRecent || recentSearches.length === 0) return null;
return (
<View style={styles.recentSearchesContainer}>
<Text style={[styles.carouselTitle, { color: isDarkMode ? colors.white : colors.black }]}>
<Animated.View
style={styles.recentSearchesContainer}
entering={FadeIn.duration(300)}
>
<Text style={styles.carouselTitle}>
Recent Searches
</Text>
{recentSearches.map((search, index) => (
<TouchableOpacity
<AnimatedTouchable
key={index}
style={styles.recentSearchItem}
onPress={() => {
setQuery(search);
Keyboard.dismiss();
}}
entering={FadeIn.duration(300).delay(index * 50)}
>
<MaterialIcons
name="history"
size={20}
color={isDarkMode ? colors.lightGray : colors.mediumGray}
color={colors.lightGray}
style={styles.recentSearchIcon}
/>
<Text style={[
styles.recentSearchText,
{ color: isDarkMode ? colors.white : colors.black }
]}>
<Text style={styles.recentSearchText}>
{search}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
const newRecentSearches = [...recentSearches];
newRecentSearches.splice(index, 1);
setRecentSearches(newRecentSearches);
AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={styles.recentSearchDeleteButton}
>
<MaterialIcons name="close" size={16} color={colors.lightGray} />
</TouchableOpacity>
</AnimatedTouchable>
))}
</View>
</Animated.View>
);
};
const renderHorizontalItem = ({ item }: { item: StreamingContent }) => {
const renderHorizontalItem = ({ item, index }: { item: StreamingContent, index: number }) => {
return (
<TouchableOpacity
<AnimatedTouchable
style={styles.horizontalItem}
onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type });
}}
entering={FadeIn.duration(500).delay(index * 100)}
activeOpacity={0.7}
>
<View style={styles.horizontalItemPosterContainer}>
<Image
@ -230,14 +405,26 @@ const SearchScreen = () => {
contentFit="cover"
transition={300}
/>
<View style={styles.itemTypeContainer}>
<Text style={styles.itemTypeText}>{item.type === 'movie' ? 'MOVIE' : 'SERIES'}</Text>
</View>
{item.imdbRating && (
<View style={styles.ratingContainer}>
<MaterialIcons name="star" size={12} color="#FFC107" />
<Text style={styles.ratingText}>{item.imdbRating}</Text>
</View>
)}
</View>
<Text
style={[styles.horizontalItemTitle, { color: isDarkMode ? colors.white : colors.black }]}
style={styles.horizontalItemTitle}
numberOfLines={2}
>
{item.name}
</Text>
</TouchableOpacity>
{item.year && (
<Text style={styles.yearText}>{item.year}</Text>
)}
</AnimatedTouchable>
);
};
@ -253,148 +440,204 @@ const SearchScreen = () => {
return movieResults.length > 0 || seriesResults.length > 0;
}, [movieResults, seriesResults]);
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
const headerHeight = headerBaseHeight + topSpacing + 60;
return (
<SafeAreaView style={[
styles.container,
{ backgroundColor: colors.black }
]}>
<View style={styles.container}>
<StatusBar
barStyle="light-content"
backgroundColor={colors.black}
backgroundColor="transparent"
translucent
/>
<View style={styles.header}>
<Text style={styles.headerTitle}>Search</Text>
<View style={[
styles.searchBar,
{
backgroundColor: colors.darkGray,
borderColor: 'transparent',
}
]}>
<MaterialIcons
name="search"
size={24}
color={colors.lightGray}
style={styles.searchIcon}
/>
<TextInput
style={[
styles.searchInput,
{ color: colors.white }
]}
placeholder="Search movies, shows..."
placeholderTextColor={colors.lightGray}
value={query}
onChangeText={setQuery}
returnKeyType="search"
keyboardAppearance="dark"
autoFocus
/>
{query.length > 0 && (
<TouchableOpacity
onPress={handleClearSearch}
style={styles.clearButton}
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
{/* Fixed position header background to prevent shifts */}
<View style={[styles.headerBackground, { height: headerHeight }]} />
<View style={{ flex: 1 }}>
{/* Header Section with proper top spacing */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<Text style={styles.headerTitle}>Search</Text>
<View style={[
styles.searchBar,
{
backgroundColor: colors.darkGray,
borderColor: 'transparent',
}
]}>
<MaterialIcons
name="search"
size={24}
color={colors.lightGray}
style={styles.searchIcon}
/>
<TextInput
style={[
styles.searchInput,
{ color: colors.white }
]}
placeholder="Search movies, shows..."
placeholderTextColor={colors.lightGray}
value={query}
onChangeText={setQuery}
returnKeyType="search"
keyboardAppearance="dark"
autoFocus
/>
{query.length > 0 && (
<TouchableOpacity
onPress={handleClearSearch}
style={styles.clearButton}
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
>
<MaterialIcons
name="close"
size={20}
color={colors.lightGray}
/>
</TouchableOpacity>
)}
</View>
</View>
{/* Content Container */}
<View style={styles.contentContainer}>
{searching ? (
<SimpleSearchAnimation />
) : searched && !hasResultsToShow ? (
<Animated.View
style={styles.emptyContainer}
entering={FadeIn.duration(300)}
>
<MaterialIcons
name="close"
size={20}
name="search-off"
size={64}
color={colors.lightGray}
/>
</TouchableOpacity>
<Text style={styles.emptyText}>
No results found
</Text>
<Text style={styles.emptySubtext}>
Try different keywords or check your spelling
</Text>
</Animated.View>
) : (
<Animated.ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollViewContent}
keyboardShouldPersistTaps="handled"
onScrollBeginDrag={Keyboard.dismiss}
entering={FadeIn.duration(300)}
showsVerticalScrollIndicator={false}
>
{!query.trim() && renderRecentSearches()}
{movieResults.length > 0 && (
<Animated.View
style={styles.carouselContainer}
entering={FadeIn.duration(300)}
>
<Text style={styles.carouselTitle}>Movies ({movieResults.length})</Text>
<FlatList
data={movieResults}
renderItem={renderHorizontalItem}
keyExtractor={item => `movie-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</Animated.View>
)}
{seriesResults.length > 0 && (
<Animated.View
style={styles.carouselContainer}
entering={FadeIn.duration(300).delay(100)}
>
<Text style={styles.carouselTitle}>TV Shows ({seriesResults.length})</Text>
<FlatList
data={seriesResults}
renderItem={renderHorizontalItem}
keyExtractor={item => `series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</Animated.View>
)}
</Animated.ScrollView>
)}
</View>
</View>
{searching ? (
<SkeletonLoader />
) : searched && !hasResultsToShow ? (
<View style={styles.emptyContainer}>
<MaterialIcons
name="search-off"
size={64}
color={isDarkMode ? colors.lightGray : colors.mediumGray}
/>
<Text style={[
styles.emptyText,
{ color: isDarkMode ? colors.white : colors.black }
]}>
No results found
</Text>
<Text style={[
styles.emptySubtext,
{ color: isDarkMode ? colors.lightGray : colors.mediumGray }
]}>
Try different keywords or check your spelling
</Text>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollViewContent}
keyboardShouldPersistTaps="handled"
onScrollBeginDrag={Keyboard.dismiss}
>
{!query.trim() && renderRecentSearches()}
{movieResults.length > 0 && (
<View style={styles.carouselContainer}>
<Text style={styles.carouselTitle}>Movies ({movieResults.length})</Text>
<FlatList
data={movieResults}
renderItem={renderHorizontalItem}
keyExtractor={item => `movie-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
{seriesResults.length > 0 && (
<View style={styles.carouselContainer}>
<Text style={styles.carouselTitle}>TV Shows ({seriesResults.length})</Text>
<FlatList
data={seriesResults}
renderItem={renderHorizontalItem}
keyExtractor={item => `series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
</ScrollView>
)}
</SafeAreaView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.black,
},
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: colors.black,
zIndex: 1,
},
contentContainer: {
flex: 1,
backgroundColor: colors.black,
paddingTop: 0,
},
header: {
paddingHorizontal: 16,
paddingTop: 40,
paddingBottom: 12,
backgroundColor: colors.black,
gap: 16,
paddingHorizontal: 20,
justifyContent: 'flex-end',
paddingBottom: 8,
backgroundColor: 'transparent',
zIndex: 2,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
color: colors.white,
letterSpacing: 0.5,
marginBottom: 12,
},
searchBarContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
searchBarWrapper: {
flex: 1,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 24,
borderRadius: 12,
paddingHorizontal: 16,
height: 48,
backgroundColor: colors.darkGray,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
backButton: {
marginRight: 10,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
searchIcon: {
marginRight: 12,
@ -403,6 +646,7 @@ const styles = StyleSheet.create({
flex: 1,
fontSize: 16,
height: '100%',
color: colors.white,
},
clearButton: {
padding: 4,
@ -412,6 +656,7 @@ const styles = StyleSheet.create({
},
scrollViewContent: {
paddingBottom: 20,
paddingHorizontal: 0,
},
carouselContainer: {
marginBottom: 24,
@ -424,7 +669,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
},
horizontalListContent: {
paddingHorizontal: 16,
paddingHorizontal: 12,
paddingRight: 8,
},
horizontalItem: {
@ -434,10 +679,12 @@ const styles = StyleSheet.create({
horizontalItemPosterContainer: {
width: HORIZONTAL_ITEM_WIDTH,
height: HORIZONTAL_POSTER_HEIGHT,
borderRadius: 8,
borderRadius: 12,
overflow: 'hidden',
backgroundColor: colors.darkBackground,
marginBottom: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
},
horizontalItemPoster: {
width: '100%',
@ -445,19 +692,30 @@ const styles = StyleSheet.create({
},
horizontalItemTitle: {
fontSize: 14,
fontWeight: '500',
fontWeight: '600',
lineHeight: 18,
textAlign: 'left',
color: colors.white,
},
yearText: {
fontSize: 12,
color: colors.mediumGray,
marginTop: 2,
},
recentSearchesContainer: {
paddingHorizontal: 0,
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.05)',
marginBottom: 8,
},
recentSearchItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 16,
marginVertical: 1,
},
recentSearchIcon: {
marginRight: 12,
@ -465,6 +723,10 @@ const styles = StyleSheet.create({
recentSearchText: {
fontSize: 16,
flex: 1,
color: colors.white,
},
recentSearchDeleteButton: {
padding: 4,
},
loadingContainer: {
flex: 1,
@ -474,6 +736,7 @@ const styles = StyleSheet.create({
loadingText: {
marginTop: 16,
fontSize: 16,
color: colors.white,
},
emptyContainer: {
flex: 1,
@ -486,14 +749,20 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
marginTop: 16,
marginBottom: 8,
color: colors.white,
},
emptySubtext: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
color: colors.lightGray,
},
skeletonContainer: {
padding: 16,
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
paddingTop: 16,
justifyContent: 'space-between',
},
skeletonVerticalItem: {
flexDirection: 'row',
@ -535,6 +804,67 @@ const styles = StyleSheet.create({
marginBottom: 16,
borderRadius: 4,
},
itemTypeContainer: {
position: 'absolute',
top: 8,
left: 8,
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
itemTypeText: {
color: colors.white,
fontSize: 8,
fontWeight: '700',
},
ratingContainer: {
position: 'absolute',
bottom: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.7)',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
},
ratingText: {
color: colors.white,
fontSize: 10,
fontWeight: '700',
marginLeft: 2,
},
simpleAnimationContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
simpleAnimationContent: {
alignItems: 'center',
},
spinnerContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
simpleAnimationText: {
color: colors.white,
fontSize: 16,
fontWeight: '600',
},
});
export default SearchScreen;

View file

@ -375,8 +375,16 @@ export class TMDBService {
* Get image URL for TMDB images
*/
getImageUrl(path: string | null, size: 'original' | 'w500' | 'w300' | 'w185' | 'profile' = 'original'): string | null {
if (!path) return null;
return `https://image.tmdb.org/t/p/${size}${path}`;
if (!path) {
logger.warn(`[TMDBService] Cannot construct image URL from null path`);
return null;
}
const baseImageUrl = 'https://image.tmdb.org/t/p/';
const fullUrl = `${baseImageUrl}${size}${path}`;
logger.log(`[TMDBService] Constructed image URL: ${fullUrl}`);
return fullUrl;
}
/**
@ -562,6 +570,8 @@ export class TMDBService {
*/
async getMovieImages(movieId: number | string): Promise<string | null> {
try {
logger.log(`[TMDBService] Fetching movie images for TMDB ID: ${movieId}`);
const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, {
headers: await this.getHeaders(),
params: await this.getParams({
@ -570,6 +580,8 @@ export class TMDBService {
});
const images = response.data;
logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for movie ID ${movieId}`);
if (images && images.logos && images.logos.length > 0) {
// First prioritize English SVG logos
const enSvgLogo = images.logos.find((logo: any) =>
@ -578,6 +590,7 @@ export class TMDBService {
logo.iso_639_1 === 'en'
);
if (enSvgLogo) {
logger.log(`[TMDBService] Found English SVG logo for movie ID ${movieId}: ${enSvgLogo.file_path}`);
return this.getImageUrl(enSvgLogo.file_path);
}
@ -588,6 +601,7 @@ export class TMDBService {
logo.iso_639_1 === 'en'
);
if (enPngLogo) {
logger.log(`[TMDBService] Found English PNG logo for movie ID ${movieId}: ${enPngLogo.file_path}`);
return this.getImageUrl(enPngLogo.file_path);
}
@ -596,6 +610,7 @@ export class TMDBService {
logo.iso_639_1 === 'en'
);
if (enLogo) {
logger.log(`[TMDBService] Found English logo for movie ID ${movieId}: ${enLogo.file_path}`);
return this.getImageUrl(enLogo.file_path);
}
@ -604,6 +619,7 @@ export class TMDBService {
logo.file_path && logo.file_path.endsWith('.svg')
);
if (svgLogo) {
logger.log(`[TMDBService] Found SVG logo for movie ID ${movieId}: ${svgLogo.file_path}`);
return this.getImageUrl(svgLogo.file_path);
}
@ -612,17 +628,20 @@ export class TMDBService {
logo.file_path && logo.file_path.endsWith('.png')
);
if (pngLogo) {
logger.log(`[TMDBService] Found PNG logo for movie ID ${movieId}: ${pngLogo.file_path}`);
return this.getImageUrl(pngLogo.file_path);
}
// Last resort: any logo
logger.log(`[TMDBService] Using first available logo for movie ID ${movieId}: ${images.logos[0].file_path}`);
return this.getImageUrl(images.logos[0].file_path);
}
logger.warn(`[TMDBService] No logos found for movie ID ${movieId}`);
return null; // No logos found
} catch (error) {
// Log error but don't throw, just return null if fetching images fails
logger.error(`Failed to get movie images for ID ${movieId}:`, error);
logger.error(`[TMDBService] Failed to get movie images for ID ${movieId}:`, error);
return null;
}
}
@ -632,6 +651,8 @@ export class TMDBService {
*/
async getTvShowImages(showId: number | string): Promise<string | null> {
try {
logger.log(`[TMDBService] Fetching TV show images for TMDB ID: ${showId}`);
const response = await axios.get(`${BASE_URL}/tv/${showId}/images`, {
headers: await this.getHeaders(),
params: await this.getParams({
@ -640,6 +661,8 @@ export class TMDBService {
});
const images = response.data;
logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for TV show ID ${showId}`);
if (images && images.logos && images.logos.length > 0) {
// First prioritize English SVG logos
const enSvgLogo = images.logos.find((logo: any) =>
@ -648,6 +671,7 @@ export class TMDBService {
logo.iso_639_1 === 'en'
);
if (enSvgLogo) {
logger.log(`[TMDBService] Found English SVG logo for TV show ID ${showId}: ${enSvgLogo.file_path}`);
return this.getImageUrl(enSvgLogo.file_path);
}
@ -658,6 +682,7 @@ export class TMDBService {
logo.iso_639_1 === 'en'
);
if (enPngLogo) {
logger.log(`[TMDBService] Found English PNG logo for TV show ID ${showId}: ${enPngLogo.file_path}`);
return this.getImageUrl(enPngLogo.file_path);
}
@ -666,6 +691,7 @@ export class TMDBService {
logo.iso_639_1 === 'en'
);
if (enLogo) {
logger.log(`[TMDBService] Found English logo for TV show ID ${showId}: ${enLogo.file_path}`);
return this.getImageUrl(enLogo.file_path);
}
@ -674,6 +700,7 @@ export class TMDBService {
logo.file_path && logo.file_path.endsWith('.svg')
);
if (svgLogo) {
logger.log(`[TMDBService] Found SVG logo for TV show ID ${showId}: ${svgLogo.file_path}`);
return this.getImageUrl(svgLogo.file_path);
}
@ -682,17 +709,20 @@ export class TMDBService {
logo.file_path && logo.file_path.endsWith('.png')
);
if (pngLogo) {
logger.log(`[TMDBService] Found PNG logo for TV show ID ${showId}: ${pngLogo.file_path}`);
return this.getImageUrl(pngLogo.file_path);
}
// Last resort: any logo
logger.log(`[TMDBService] Using first available logo for TV show ID ${showId}: ${images.logos[0].file_path}`);
return this.getImageUrl(images.logos[0].file_path);
}
logger.warn(`[TMDBService] No logos found for TV show ID ${showId}`);
return null; // No logos found
} catch (error) {
// Log error but don't throw, just return null if fetching images fails
logger.error(`Failed to get TV show images for ID ${showId}:`, error);
logger.error(`[TMDBService] Failed to get TV show images for ID ${showId}:`, error);
return null;
}
}
@ -702,11 +732,21 @@ export class TMDBService {
*/
async getContentLogo(type: 'movie' | 'tv', id: number | string): Promise<string | null> {
try {
return type === 'movie'
logger.log(`[TMDBService] Getting content logo for ${type} with ID ${id}`);
const result = type === 'movie'
? await this.getMovieImages(id)
: await this.getTvShowImages(id);
if (result) {
logger.log(`[TMDBService] Successfully retrieved logo for ${type} ID ${id}: ${result}`);
} else {
logger.warn(`[TMDBService] No logo found for ${type} ID ${id}`);
}
return result;
} catch (error) {
logger.error(`Failed to get content logo for ${type} ID ${id}:`, error);
logger.error(`[TMDBService] Failed to get content logo for ${type} ID ${id}:`, error);
return null;
}
}

83
src/utils/logoUtils.ts Normal file
View file

@ -0,0 +1,83 @@
import { logger } from './logger';
/**
* Checks if a URL is a valid Metahub logo by performing a HEAD request
* @param url The Metahub logo URL to check
* @returns True if the logo is valid, false otherwise
*/
export const isValidMetahubLogo = async (url: string): Promise<boolean> => {
if (!url || !url.includes('metahub.space')) {
return false;
}
try {
const response = await fetch(url, { method: 'HEAD' });
// Check if request was successful
if (!response.ok) {
logger.warn(`[logoUtils] Logo URL returned status ${response.status}: ${url}`);
return false;
}
// Check file size to detect "Missing Image" placeholders
const contentLength = response.headers.get('content-length');
const fileSize = contentLength ? parseInt(contentLength, 10) : 0;
// If content-length header is missing, we can't check file size, so assume it's valid
if (!contentLength) {
logger.warn(`[logoUtils] No content-length header for URL: ${url}`);
return true; // Give it the benefit of the doubt
}
// If file size is suspiciously small, it might be a "Missing Image" placeholder
// Check for extremely small files (less than 100 bytes) which are definitely placeholders
if (fileSize < 100) {
logger.warn(`[logoUtils] Logo URL returned extremely small file (${fileSize} bytes), likely a placeholder: ${url}`);
return false;
}
// For file sizes between 100-500 bytes, they might be small legitimate SVG files
// So we'll allow them through
return true;
} catch (error) {
logger.error(`[logoUtils] Error checking logo URL: ${url}`, error);
// Don't fail hard on network errors, let the image component try to load it
return true;
}
};
/**
* Utility to determine if a URL is likely to be a valid logo
* @param url The logo URL to check
* @returns True if the URL pattern suggests a valid logo
*/
export const hasValidLogoFormat = (url: string | null): boolean => {
if (!url) return false;
// Only reject explicit placeholders, otherwise be permissive
if (url.includes('missing') || url.includes('placeholder.') || url.includes('not-found')) {
return false;
}
return true; // Allow most URLs to pass through
};
/**
* Checks if a URL is from Metahub
* @param url The URL to check
* @returns True if the URL is from Metahub
*/
export const isMetahubUrl = (url: string | null): boolean => {
if (!url) return false;
return url.includes('metahub.space');
};
/**
* Checks if a URL is from TMDB
* @param url The URL to check
* @returns True if the URL is from TMDB
*/
export const isTmdbUrl = (url: string | null): boolean => {
if (!url) return false;
return url.includes('themoviedb.org') || url.includes('tmdb.org') || url.includes('image.tmdb.org');
};