import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
FlatList,
TouchableOpacity,
ActivityIndicator,
useColorScheme,
SafeAreaView,
StatusBar,
Keyboard,
Dimensions,
ScrollView,
Animated as RNAnimated,
Pressable,
Platform,
Easing,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { catalogService, StreamingContent } from '../services/catalogService';
import { Image } from 'expo-image';
import debounce from 'lodash/debounce';
import { DropUpMenu } from '../components/home/DropUpMenu';
import { DeviceEventEmitter, Share } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
interpolate,
withSpring,
withDelay,
} 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';
import { useTheme } from '../contexts/ThemeContext';
import LoadingSpinner from '../components/common/LoadingSpinner';
const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
const TAB_BAR_HEIGHT = 85;
// Tablet-optimized poster sizes
const HORIZONTAL_ITEM_WIDTH = isTablet ? width * 0.18 : width * 0.3;
const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5;
const POSTER_WIDTH = isTablet ? 70 : 90;
const POSTER_HEIGHT = isTablet ? 105 : 135;
const RECENT_SEARCHES_KEY = 'recent_searches';
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;
const { currentTheme } = useTheme();
React.useEffect(() => {
const pulse = RNAnimated.loop(
RNAnimated.sequence([
RNAnimated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
RNAnimated.timing(pulseAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
])
);
pulse.start();
return () => pulse.stop();
}, [pulseAnim]);
const opacity = pulseAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 0.7],
});
const renderSkeletonItem = () => (
);
return (
{[...Array(5)].map((_, index) => (
{index === 0 && (
)}
{renderSkeletonItem()}
))}
);
};
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;
const { currentTheme } = useTheme();
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 (
Searching
);
};
const SearchScreen = () => {
const navigation = useNavigation>();
const isDarkMode = true;
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
const [recentSearches, setRecentSearches] = useState([]);
const [showRecent, setShowRecent] = useState(true);
const inputRef = useRef(null);
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
// DropUpMenu state
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState(null);
const [isSaved, setIsSaved] = useState(false);
const [isWatched, setIsWatched] = useState(false);
const [refreshFlag, setRefreshFlag] = React.useState(false);
// Update isSaved and isWatched when selectedItem changes
useEffect(() => {
if (!selectedItem) return;
(async () => {
// Check if item is in library
const items = await catalogService.getLibraryItems();
const found = items.find((libItem: any) => libItem.id === selectedItem.id && libItem.type === selectedItem.type);
setIsSaved(!!found);
// Check watched status
const val = await AsyncStorage.getItem(`watched:${selectedItem.type}:${selectedItem.id}`);
setIsWatched(val === 'true');
})();
}, [selectedItem]);
// 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({
headerShown: false,
});
}, [navigation]);
useEffect(() => {
loadRecentSearches();
// Cleanup function to cancel pending searches on unmount
return () => {
debouncedSearch.cancel();
};
}, []);
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 {
// Add a small delay to allow keyboard to dismiss smoothly before navigation
if (Platform.OS === 'android') {
setTimeout(() => {
navigation.goBack();
}, 100);
} else {
navigation.goBack();
}
}
};
const loadRecentSearches = async () => {
try {
const savedSearches = await AsyncStorage.getItem(RECENT_SEARCHES_KEY);
if (savedSearches) {
setRecentSearches(JSON.parse(savedSearches));
}
} catch (error) {
logger.error('Failed to load recent searches:', error);
}
};
const saveRecentSearch = async (searchQuery: string) => {
try {
setRecentSearches(prevSearches => {
const newRecentSearches = [
searchQuery,
...prevSearches.filter(s => s !== searchQuery)
].slice(0, MAX_RECENT_SEARCHES);
// Save to AsyncStorage
AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
return newRecentSearches;
});
} catch (error) {
logger.error('Failed to save recent search:', error);
}
};
const debouncedSearch = useCallback(
debounce(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
setSearching(false);
return;
}
try {
logger.info('Performing search for:', searchQuery);
const searchResults = await catalogService.searchContentCinemeta(searchQuery);
setResults(searchResults);
if (searchResults.length > 0) {
await saveRecentSearch(searchQuery);
}
logger.info('Search completed, found', searchResults.length, 'results');
} catch (error) {
logger.error('Search failed:', error);
setResults([]);
} finally {
setSearching(false);
}
}, 800),
[]
);
useEffect(() => {
if (query.trim() && query.trim().length >= 2) {
setSearching(true);
setSearched(true);
setShowRecent(false);
debouncedSearch(query);
} else if (query.trim().length < 2 && query.trim().length > 0) {
// Show that we're waiting for more characters
setSearching(false);
setSearched(false);
setShowRecent(false);
setResults([]);
} else {
// Cancel any pending search when query is cleared
debouncedSearch.cancel();
setResults([]);
setSearched(false);
setSearching(false);
setShowRecent(true);
loadRecentSearches();
}
// Cleanup function to cancel pending searches
return () => {
debouncedSearch.cancel();
};
}, [query, debouncedSearch]);
const handleClearSearch = () => {
setQuery('');
setResults([]);
setSearched(false);
setShowRecent(true);
loadRecentSearches();
inputRef.current?.focus();
};
const renderRecentSearches = () => {
if (!showRecent || recentSearches.length === 0) return null;
return (
Recent Searches
{recentSearches.map((search, index) => (
{
setQuery(search);
Keyboard.dismiss();
}}
entering={FadeIn.duration(300).delay(index * 50)}
>
{search}
{
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}
>
))}
);
};
const SearchResultItem = ({ item, index, navigation, setSelectedItem, setMenuVisible, currentTheme, refreshFlag }: {
item: StreamingContent;
index: number;
navigation: any;
setSelectedItem: (item: StreamingContent) => void;
setMenuVisible: (visible: boolean) => void;
currentTheme: any;
refreshFlag: boolean;
}) => {
const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary);
const [watched, setWatched] = React.useState(false);
// Re-check status when refreshFlag changes
React.useEffect(() => {
AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
const items = catalogService.getLibraryItems();
const found = items.find((libItem: any) => libItem.id === item.id && libItem.type === item.type);
setInLibrary(!!found);
}, [refreshFlag, item.id, item.type]);
React.useEffect(() => {
const updateWatched = () => {
AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
};
updateWatched();
const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched);
return () => sub.remove();
}, [item.id, item.type]);
React.useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type);
setInLibrary(!!found);
});
return () => unsubscribe();
}, [item.id, item.type]);
return (
{
navigation.navigate('Metadata', { id: item.id, type: item.type });
}}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
// Do NOT toggle refreshFlag here
}}
delayLongPress={300}
entering={FadeIn.duration(300).delay(index * 50)}
activeOpacity={0.7}
>
{/* Bookmark and watched icons top right, bookmark to the left of watched */}
{inLibrary && (
)}
{watched && (
)}
{/* 'series'/'movie' text in original place */}
{item.type === 'movie' ? 'MOVIE' : 'SERIES'}
{item.imdbRating && (
{item.imdbRating}
)}
{item.name}
{item.year && (
{item.year}
)}
);
};
const movieResults = useMemo(() => {
return results.filter(item => item.type === 'movie');
}, [results]);
const seriesResults = useMemo(() => {
return results.filter(item => item.type === 'series');
}, [results]);
const hasResultsToShow = useMemo(() => {
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;
useEffect(() => {
const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', () => setRefreshFlag(f => !f));
const librarySub = catalogService.subscribeToLibraryUpdates(() => setRefreshFlag(f => !f));
const focusSub = navigation.addListener('focus', () => setRefreshFlag(f => !f));
return () => {
watchedSub.remove();
librarySub();
focusSub();
};
}, []);
return (
{/* Fixed position header background to prevent shifts */}
{/* Header Section with proper top spacing */}
Search
{query.length > 0 && (
)}
{/* Content Container */}
{searching ? (
) : query.trim().length === 1 ? (
Keep typing...
Type at least 2 characters to search
) : searched && !hasResultsToShow ? (
No results found
Try different keywords or check your spelling
) : (
{!query.trim() && renderRecentSearches()}
{movieResults.length > 0 && (
Movies ({movieResults.length})
(
)}
keyExtractor={item => `movie-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
extraData={refreshFlag}
/>
)}
{seriesResults.length > 0 && (
TV Shows ({seriesResults.length})
(
)}
keyExtractor={item => `series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
extraData={refreshFlag}
/>
)}
)}
{/* DropUpMenu integration for search results */}
{selectedItem && (
setMenuVisible(false)}
item={selectedItem}
isSaved={isSaved}
isWatched={isWatched}
onOptionSelect={async (option: string) => {
if (!selectedItem) return;
switch (option) {
case 'share': {
let url = '';
if (selectedItem.id) {
url = `https://www.imdb.com/title/${selectedItem.id}/`;
}
const message = `${selectedItem.name}\n${url}`;
Share.share({ message, url, title: selectedItem.name });
break;
}
case 'library': {
if (isSaved) {
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
setIsSaved(false);
} else {
await catalogService.addToLibrary(selectedItem);
setIsSaved(true);
}
break;
}
case 'watched': {
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !isWatched;
await AsyncStorage.setItem(key, newWatched ? 'true' : 'false');
setIsWatched(newWatched);
break;
}
default:
break;
}
}}
/>
)}
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
},
contentContainer: {
flex: 1,
paddingTop: 0,
},
header: {
paddingHorizontal: 15,
justifyContent: 'flex-end',
paddingBottom: 0,
backgroundColor: 'transparent',
zIndex: 2,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.5,
marginBottom: 12,
},
searchBarContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
height: 48,
},
searchBarWrapper: {
flex: 1,
height: 48,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
paddingHorizontal: 16,
height: '100%',
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
searchIcon: {
marginRight: 12,
},
searchInput: {
flex: 1,
fontSize: 16,
height: '100%',
},
clearButton: {
padding: 4,
},
scrollView: {
flex: 1,
},
scrollViewContent: {
paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav
paddingHorizontal: 0,
},
carouselContainer: {
marginBottom: isTablet ? 32 : 24,
},
carouselTitle: {
fontSize: isTablet ? 20 : 18,
fontWeight: '700',
marginBottom: isTablet ? 16 : 12,
paddingHorizontal: 16,
},
horizontalListContent: {
paddingHorizontal: isTablet ? 16 : 12,
paddingRight: isTablet ? 12 : 8,
},
horizontalItem: {
width: HORIZONTAL_ITEM_WIDTH,
marginRight: isTablet ? 16 : 12,
},
horizontalItemPosterContainer: {
width: HORIZONTAL_ITEM_WIDTH,
height: HORIZONTAL_POSTER_HEIGHT,
borderRadius: 16,
overflow: 'hidden',
marginBottom: 8,
borderWidth: 1,
},
horizontalItemPoster: {
width: '100%',
height: '100%',
},
horizontalItemTitle: {
fontSize: isTablet ? 12 : 14,
fontWeight: '600',
lineHeight: isTablet ? 16 : 18,
textAlign: 'left',
},
yearText: {
fontSize: isTablet ? 10 : 12,
marginTop: 2,
},
recentSearchesContainer: {
paddingHorizontal: 16,
paddingBottom: isTablet ? 24 : 16,
paddingTop: isTablet ? 12 : 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.05)',
marginBottom: isTablet ? 16 : 8,
},
recentSearchItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: isTablet ? 12 : 10,
paddingHorizontal: 16,
marginVertical: 1,
},
recentSearchIcon: {
marginRight: 12,
},
recentSearchText: {
fontSize: 16,
flex: 1,
},
recentSearchDeleteButton: {
padding: 4,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: isTablet ? 64 : 32,
paddingBottom: isTablet ? 120 : 100,
},
emptyText: {
fontSize: 18,
fontWeight: 'bold',
marginTop: 16,
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
skeletonContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
paddingTop: 16,
justifyContent: 'space-between',
},
skeletonVerticalItem: {
flexDirection: 'row',
marginBottom: 16,
},
skeletonPoster: {
width: POSTER_WIDTH,
height: POSTER_HEIGHT,
borderRadius: 12,
},
skeletonItemDetails: {
flex: 1,
marginLeft: 16,
justifyContent: 'center',
},
skeletonMetaRow: {
flexDirection: 'row',
gap: 8,
marginTop: 8,
},
skeletonTitle: {
height: 20,
width: '80%',
marginBottom: 8,
borderRadius: 4,
},
skeletonMeta: {
height: 14,
width: '30%',
borderRadius: 4,
},
skeletonSectionHeader: {
height: 24,
width: '40%',
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: {
fontSize: isTablet ? 7 : 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: {
fontSize: isTablet ? 9 : 10,
fontWeight: '700',
marginLeft: 2,
},
simpleAnimationContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
simpleAnimationContent: {
alignItems: 'center',
},
spinnerContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
simpleAnimationText: {
fontSize: 16,
fontWeight: '600',
},
watchedIndicator: {
position: 'absolute',
top: 8,
right: 8,
borderRadius: 12,
padding: 2,
zIndex: 2,
backgroundColor: 'transparent',
},
libraryBadge: {
position: 'absolute',
top: 8,
right: 36,
borderRadius: 8,
padding: 4,
zIndex: 2,
backgroundColor: 'transparent',
},
});
export default SearchScreen;