mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-05 09:19:43 +00:00
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:
parent
5e81a14ebb
commit
5a64adec22
5 changed files with 663 additions and 145 deletions
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
83
src/utils/logoUtils.ts
Normal 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');
|
||||
};
|
||||
Loading…
Reference in a new issue