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'; } from 'react-native-reanimated';
import { StreamingContent } from '../../services/catalogService'; import { StreamingContent } from '../../services/catalogService';
import { SkeletonFeatured } from './SkeletonLoaders'; import { SkeletonFeatured } from './SkeletonLoaders';
import { isValidMetahubLogo, hasValidLogoFormat } from '../../utils/logoUtils';
interface FeaturedContentProps { interface FeaturedContentProps {
featuredContent: StreamingContent | null; featuredContent: StreamingContent | null;
@ -45,6 +46,8 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
const [logoUrl, setLogoUrl] = useState<string | null>(null); const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [bannerUrl, setBannerUrl] = useState<string | null>(null); const [bannerUrl, setBannerUrl] = useState<string | null>(null);
const prevContentIdRef = useRef<string | null>(null); const prevContentIdRef = useRef<string | null>(null);
// Add state for tracking logo load errors
const [logoLoadError, setLogoLoadError] = useState(false);
// Animation values // Animation values
const posterOpacity = useSharedValue(0); const posterOpacity = useSharedValue(0);
@ -74,15 +77,37 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
if (imageCache[url]) return true; if (imageCache[url]) return true;
try { 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); await ExpoImage.prefetch(url);
imageCache[url] = true; imageCache[url] = true;
console.log(`[FeaturedContent] Successfully preloaded image: ${url}`);
return true; return true;
} catch (error) { } catch (error) {
console.error('Error preloading image:', error); console.error('[FeaturedContent] Error preloading image:', error);
return false; return false;
} }
}; };
// Reset logo error state when content changes
useEffect(() => {
setLogoLoadError(false);
}, [featuredContent?.id]);
// Load poster and logo // Load poster and logo
useEffect(() => { useEffect(() => {
if (!featuredContent) return; if (!featuredContent) return;
@ -124,6 +149,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
duration: 500, duration: 500,
easing: Easing.bezier(0.25, 0.1, 0.25, 1) 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 <Animated.View
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]} style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
> >
{featuredContent.logo ? ( {featuredContent.logo && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}> <Animated.View style={logoAnimatedStyle}>
<ExpoImage <ExpoImage
source={{ uri: logoUrl || featuredContent.logo }} source={{ uri: logoUrl || featuredContent.logo }}
@ -173,6 +202,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
contentFit="contain" contentFit="contain"
cachePolicy="memory-disk" cachePolicy="memory-disk"
transition={400} transition={400}
onError={() => {
console.warn(`[FeaturedContent] Logo failed to load: ${featuredContent.logo}`);
setLogoLoadError(true);
}}
/> />
</Animated.View> </Animated.View>
) : ( ) : (

View file

@ -56,6 +56,7 @@ import { TMDBService } from '../services/tmdbService';
import { storageService } from '../services/storageService'; import { storageService } from '../services/storageService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { useGenres } from '../contexts/GenreContext'; import { useGenres } from '../contexts/GenreContext';
import { isValidMetahubLogo, isMetahubUrl, isTmdbUrl } from '../utils/logoUtils';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
@ -264,6 +265,14 @@ const MetadataScreen = () => {
episodeId?: string; episodeId?: string;
} | null>(null); } | 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 // Add wrapper for toggleLibrary that includes haptic feedback
const handleToggleLibrary = useCallback(() => { const handleToggleLibrary = useCallback(() => {
// Trigger appropriate haptic feedback based on action // Trigger appropriate haptic feedback based on action
@ -324,7 +333,7 @@ const MetadataScreen = () => {
logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub for ${imdbId}`); 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 { try {
const response = await fetch(metahubUrl, { method: 'HEAD' }); const response = await fetch(metahubUrl, { method: 'HEAD' });
if (response.ok) { if (response.ok) {
@ -340,6 +349,8 @@ const MetadataScreen = () => {
logo: metahubUrl logo: metahubUrl
})); }));
return; // Exit if Metahub logo was found return; // Exit if Metahub logo was found
} else {
logger.warn(`[MetadataScreen] Metahub logo request failed with status ${response.status}`);
} }
} catch (metahubError) { } catch (metahubError) {
logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError);
@ -367,8 +378,16 @@ const MetadataScreen = () => {
logo: logoUrl logo: logoUrl
})); }));
} else { } 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) { } catch (error) {
logger.error('[MetadataScreen] Failed to fetch logo from all sources:', { logger.error('[MetadataScreen] Failed to fetch logo from all sources:', {
@ -385,6 +404,7 @@ const MetadataScreen = () => {
- Content ID: ${id} - Content ID: ${id}
- Content Type: ${type} - Content Type: ${type}
- Logo URL: ${metadata.logo} - Logo URL: ${metadata.logo}
- Source: ${isMetahubUrl(metadata.logo) ? 'Metahub' : (isTmdbUrl(metadata.logo) ? 'TMDB' : 'Other')}
`); `);
} }
}, [id, type, metadata, setMetadata, imdbId]); }, [id, type, metadata, setMetadata, imdbId]);
@ -1077,12 +1097,16 @@ const MetadataScreen = () => {
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerTitleContainer}> <View style={styles.headerTitleContainer}>
{metadata.logo ? ( {metadata.logo && !logoLoadError ? (
<Image <Image
source={{ uri: metadata.logo }} source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo} style={styles.floatingHeaderLogo}
contentFit="contain" contentFit="contain"
transition={150} transition={150}
onError={() => {
logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/> />
) : ( ) : (
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text> <Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
@ -1120,12 +1144,16 @@ const MetadataScreen = () => {
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerTitleContainer}> <View style={styles.headerTitleContainer}>
{metadata.logo ? ( {metadata.logo && !logoLoadError ? (
<Image <Image
source={{ uri: metadata.logo }} source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo} style={styles.floatingHeaderLogo}
contentFit="contain" contentFit="contain"
transition={150} transition={150}
onError={() => {
logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/> />
) : ( ) : (
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text> <Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
@ -1181,12 +1209,16 @@ const MetadataScreen = () => {
{/* Title */} {/* Title */}
<View style={styles.logoContainer}> <View style={styles.logoContainer}>
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}> <Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
{metadata.logo ? ( {metadata.logo && !logoLoadError ? (
<Image <Image
source={{ uri: metadata.logo }} source={{ uri: metadata.logo }}
style={styles.titleLogo} style={styles.titleLogo}
contentFit="contain" contentFit="contain"
transition={300} transition={300}
onError={() => {
logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/> />
) : ( ) : (
<Text style={styles.heroTitle}>{metadata.name}</Text> <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 { import {
View, View,
Text, Text,
@ -14,6 +14,9 @@ import {
Dimensions, Dimensions,
ScrollView, ScrollView,
Animated as RNAnimated, Animated as RNAnimated,
Pressable,
Platform,
Easing,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NavigationProp } 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 { Image } from 'expo-image';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import AsyncStorage from '@react-native-async-storage/async-storage'; 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 { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { BlurView } from 'expo-blur';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const HORIZONTAL_ITEM_WIDTH = width * 0.3; 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 PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster';
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
const SkeletonLoader = () => { const SkeletonLoader = () => {
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; 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 SearchScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = true; const isDarkMode = true;
@ -100,6 +184,30 @@ const SearchScreen = () => {
const [searched, setSearched] = useState(false); const [searched, setSearched] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>([]); const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [showRecent, setShowRecent] = useState(true); 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(() => { React.useLayoutEffect(() => {
navigation.setOptions({ navigation.setOptions({
@ -111,6 +219,55 @@ const SearchScreen = () => {
loadRecentSearches(); 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 () => { const loadRecentSearches = async () => {
try { try {
const savedSearches = await AsyncStorage.getItem(RECENT_SEARCHES_KEY); const savedSearches = await AsyncStorage.getItem(RECENT_SEARCHES_KEY);
@ -147,7 +304,9 @@ const SearchScreen = () => {
try { try {
const searchResults = await catalogService.searchContentCinemeta(searchQuery); const searchResults = await catalogService.searchContentCinemeta(searchQuery);
setResults(searchResults); setResults(searchResults);
await saveRecentSearch(searchQuery); if (searchResults.length > 0) {
await saveRecentSearch(searchQuery);
}
} catch (error) { } catch (error) {
logger.error('Search failed:', error); logger.error('Search failed:', error);
setResults([]); setResults([]);
@ -178,50 +337,66 @@ const SearchScreen = () => {
setSearched(false); setSearched(false);
setShowRecent(true); setShowRecent(true);
loadRecentSearches(); loadRecentSearches();
inputRef.current?.focus();
}; };
const renderRecentSearches = () => { const renderRecentSearches = () => {
if (!showRecent || recentSearches.length === 0) return null; if (!showRecent || recentSearches.length === 0) return null;
return ( return (
<View style={styles.recentSearchesContainer}> <Animated.View
<Text style={[styles.carouselTitle, { color: isDarkMode ? colors.white : colors.black }]}> style={styles.recentSearchesContainer}
entering={FadeIn.duration(300)}
>
<Text style={styles.carouselTitle}>
Recent Searches Recent Searches
</Text> </Text>
{recentSearches.map((search, index) => ( {recentSearches.map((search, index) => (
<TouchableOpacity <AnimatedTouchable
key={index} key={index}
style={styles.recentSearchItem} style={styles.recentSearchItem}
onPress={() => { onPress={() => {
setQuery(search); setQuery(search);
Keyboard.dismiss(); Keyboard.dismiss();
}} }}
entering={FadeIn.duration(300).delay(index * 50)}
> >
<MaterialIcons <MaterialIcons
name="history" name="history"
size={20} size={20}
color={isDarkMode ? colors.lightGray : colors.mediumGray} color={colors.lightGray}
style={styles.recentSearchIcon} style={styles.recentSearchIcon}
/> />
<Text style={[ <Text style={styles.recentSearchText}>
styles.recentSearchText,
{ color: isDarkMode ? colors.white : colors.black }
]}>
{search} {search}
</Text> </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 ( return (
<TouchableOpacity <AnimatedTouchable
style={styles.horizontalItem} style={styles.horizontalItem}
onPress={() => { onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type }); navigation.navigate('Metadata', { id: item.id, type: item.type });
}} }}
entering={FadeIn.duration(500).delay(index * 100)}
activeOpacity={0.7}
> >
<View style={styles.horizontalItemPosterContainer}> <View style={styles.horizontalItemPosterContainer}>
<Image <Image
@ -230,14 +405,26 @@ const SearchScreen = () => {
contentFit="cover" contentFit="cover"
transition={300} 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> </View>
<Text <Text
style={[styles.horizontalItemTitle, { color: isDarkMode ? colors.white : colors.black }]} style={styles.horizontalItemTitle}
numberOfLines={2} numberOfLines={2}
> >
{item.name} {item.name}
</Text> </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; return movieResults.length > 0 || seriesResults.length > 0;
}, [movieResults, seriesResults]); }, [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 ( return (
<SafeAreaView style={[ <View style={styles.container}>
styles.container,
{ backgroundColor: colors.black }
]}>
<StatusBar <StatusBar
barStyle="light-content" barStyle="light-content"
backgroundColor={colors.black} backgroundColor="transparent"
translucent
/> />
<View style={styles.header}> {/* Fixed position header background to prevent shifts */}
<Text style={styles.headerTitle}>Search</Text> <View style={[styles.headerBackground, { height: headerHeight }]} />
<View style={[
styles.searchBar, <View style={{ flex: 1 }}>
{ {/* Header Section with proper top spacing */}
backgroundColor: colors.darkGray, <View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
borderColor: 'transparent', <Text style={styles.headerTitle}>Search</Text>
} <View style={[
]}> styles.searchBar,
<MaterialIcons {
name="search" backgroundColor: colors.darkGray,
size={24} borderColor: 'transparent',
color={colors.lightGray} }
style={styles.searchIcon} ]}>
/> <MaterialIcons
<TextInput name="search"
style={[ size={24}
styles.searchInput, color={colors.lightGray}
{ color: colors.white } style={styles.searchIcon}
]} />
placeholder="Search movies, shows..." <TextInput
placeholderTextColor={colors.lightGray} style={[
value={query} styles.searchInput,
onChangeText={setQuery} { color: colors.white }
returnKeyType="search" ]}
keyboardAppearance="dark" placeholder="Search movies, shows..."
autoFocus placeholderTextColor={colors.lightGray}
/> value={query}
{query.length > 0 && ( onChangeText={setQuery}
<TouchableOpacity returnKeyType="search"
onPress={handleClearSearch} keyboardAppearance="dark"
style={styles.clearButton} autoFocus
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} />
{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 <MaterialIcons
name="close" name="search-off"
size={20} size={64}
color={colors.lightGray} 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>
</View> </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>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, 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: { header: {
paddingHorizontal: 16, paddingHorizontal: 20,
paddingTop: 40, justifyContent: 'flex-end',
paddingBottom: 12, paddingBottom: 8,
backgroundColor: colors.black, backgroundColor: 'transparent',
gap: 16, zIndex: 2,
}, },
headerTitle: { headerTitle: {
fontSize: 32, fontSize: 32,
fontWeight: '800', fontWeight: '800',
color: colors.white, color: colors.white,
letterSpacing: 0.5, letterSpacing: 0.5,
marginBottom: 12,
},
searchBarContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
searchBarWrapper: {
flex: 1,
}, },
searchBar: { searchBar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
borderRadius: 24, borderRadius: 12,
paddingHorizontal: 16, paddingHorizontal: 16,
height: 48, 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: { searchIcon: {
marginRight: 12, marginRight: 12,
@ -403,6 +646,7 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
fontSize: 16, fontSize: 16,
height: '100%', height: '100%',
color: colors.white,
}, },
clearButton: { clearButton: {
padding: 4, padding: 4,
@ -412,6 +656,7 @@ const styles = StyleSheet.create({
}, },
scrollViewContent: { scrollViewContent: {
paddingBottom: 20, paddingBottom: 20,
paddingHorizontal: 0,
}, },
carouselContainer: { carouselContainer: {
marginBottom: 24, marginBottom: 24,
@ -424,7 +669,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
horizontalListContent: { horizontalListContent: {
paddingHorizontal: 16, paddingHorizontal: 12,
paddingRight: 8, paddingRight: 8,
}, },
horizontalItem: { horizontalItem: {
@ -434,10 +679,12 @@ const styles = StyleSheet.create({
horizontalItemPosterContainer: { horizontalItemPosterContainer: {
width: HORIZONTAL_ITEM_WIDTH, width: HORIZONTAL_ITEM_WIDTH,
height: HORIZONTAL_POSTER_HEIGHT, height: HORIZONTAL_POSTER_HEIGHT,
borderRadius: 8, borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: colors.darkBackground, backgroundColor: colors.darkBackground,
marginBottom: 8, marginBottom: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
}, },
horizontalItemPoster: { horizontalItemPoster: {
width: '100%', width: '100%',
@ -445,19 +692,30 @@ const styles = StyleSheet.create({
}, },
horizontalItemTitle: { horizontalItemTitle: {
fontSize: 14, fontSize: 14,
fontWeight: '500', fontWeight: '600',
lineHeight: 18, lineHeight: 18,
textAlign: 'left', textAlign: 'left',
color: colors.white,
},
yearText: {
fontSize: 12,
color: colors.mediumGray,
marginTop: 2,
}, },
recentSearchesContainer: { recentSearchesContainer: {
paddingHorizontal: 0, paddingHorizontal: 16,
paddingBottom: 16, paddingBottom: 16,
paddingTop: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.05)',
marginBottom: 8,
}, },
recentSearchItem: { recentSearchItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 16,
marginVertical: 1,
}, },
recentSearchIcon: { recentSearchIcon: {
marginRight: 12, marginRight: 12,
@ -465,6 +723,10 @@ const styles = StyleSheet.create({
recentSearchText: { recentSearchText: {
fontSize: 16, fontSize: 16,
flex: 1, flex: 1,
color: colors.white,
},
recentSearchDeleteButton: {
padding: 4,
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
@ -474,6 +736,7 @@ const styles = StyleSheet.create({
loadingText: { loadingText: {
marginTop: 16, marginTop: 16,
fontSize: 16, fontSize: 16,
color: colors.white,
}, },
emptyContainer: { emptyContainer: {
flex: 1, flex: 1,
@ -486,14 +749,20 @@ const styles = StyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
marginTop: 16, marginTop: 16,
marginBottom: 8, marginBottom: 8,
color: colors.white,
}, },
emptySubtext: { emptySubtext: {
fontSize: 14, fontSize: 14,
textAlign: 'center', textAlign: 'center',
lineHeight: 20, lineHeight: 20,
color: colors.lightGray,
}, },
skeletonContainer: { skeletonContainer: {
padding: 16, flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
paddingTop: 16,
justifyContent: 'space-between',
}, },
skeletonVerticalItem: { skeletonVerticalItem: {
flexDirection: 'row', flexDirection: 'row',
@ -535,6 +804,67 @@ const styles = StyleSheet.create({
marginBottom: 16, marginBottom: 16,
borderRadius: 4, 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; export default SearchScreen;

View file

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