import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { View, Text, TextInput, TouchableOpacity, StatusBar, Keyboard, Dimensions, ScrollView, Platform, } from 'react-native'; import { useNavigation, useFocusEffect, useRoute } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { useTranslation } from 'react-i18next'; import { MaterialIcons } from '@expo/vector-icons'; import { catalogService, StreamingContent, GroupedSearchResults, AddonSearchResults } from '../services/catalogService'; import debounce from 'lodash/debounce'; import { DropUpMenu } from '../components/home/DropUpMenu'; import { DeviceEventEmitter, Share } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; import Animated, { useAnimatedStyle, useSharedValue, withTiming, interpolate, } from 'react-native-reanimated'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; import ScreenHeader from '../components/common/ScreenHeader'; import { useScrollToTop } from '../contexts/ScrollToTopContext'; import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { useSettings } from '../hooks/useSettings'; import { useTraktContext } from '../contexts/TraktContext'; import { useToast } from '../contexts/ToastContext'; // Import extracted search components import { DiscoverCatalog, isTablet, isLargeTablet, isTV, RECENT_SEARCHES_KEY, MAX_RECENT_SEARCHES, } from '../components/search/searchUtils'; import { searchStyles as styles } from '../components/search/searchStyles'; import { AddonSection } from '../components/search/AddonSection'; import { DiscoverSection } from '../components/search/DiscoverSection'; import { DiscoverBottomSheets } from '../components/search/DiscoverBottomSheets'; import LoadingSpinner from '../components/common/LoadingSpinner'; const { width } = Dimensions.get('window'); const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); // Add constants for saved settings keys const DISCOVER_TYPE_KEY = 'discover_selected_type'; const DISCOVER_CATALOG_KEY = 'discover_selected_catalog'; const DISCOVER_GENRE_KEY = 'discover_selected_genre'; const SearchScreen = () => { const { t } = useTranslation(); const { settings } = useSettings(); const navigation = useNavigation>(); const route = useRoute(); const { addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection, isInWatchlist, isInCollection } = useTraktContext(); const { showSuccess, showInfo } = useToast(); const [query, setQuery] = useState(''); const [results, setResults] = useState({ byAddon: [], allResults: [] }); 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(); const liveSearchHandle = useRef<{ cancel: () => void; done: Promise } | null>(null); const addonOrderRankRef = useRef>({}); const isInitialMount = useRef(true); const isMounted = useRef(true); const scrollViewRef = useRef(null); // Discover section state const [discoverCatalogs, setDiscoverCatalogs] = useState([]); const [selectedCatalog, setSelectedCatalog] = useState(null); const [selectedDiscoverType, setSelectedDiscoverType] = useState('movie'); const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState(null); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [discoverResults, setDiscoverResults] = useState([]); const [pendingDiscoverResults, setPendingDiscoverResults] = useState([]); const [discoverLoading, setDiscoverLoading] = useState(false); const [discoverInitialized, setDiscoverInitialized] = useState(false); // Bottom sheet refs const typeSheetRef = useRef(null); const catalogSheetRef = useRef(null); const genreSheetRef = useRef(null); // DropUpMenu state const [menuVisible, setMenuVisible] = useState(false); const [selectedItem, setSelectedItem] = useState(null); const [isSaved, setIsSaved] = useState(false); const [isWatched, setIsWatched] = useState(false); // Animation values const searchBarWidth = useSharedValue(width - 32); const backButtonOpacity = useSharedValue(0); // Scroll to top handler const scrollToTop = useCallback(() => { scrollViewRef.current?.scrollTo({ y: 0, animated: true }); }, []); useScrollToTop('Search', scrollToTop); useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; }; }, []); // Load saved discover settings on mount useEffect(() => { const loadSavedDiscoverSettings = async () => { try { // Load saved type const savedType = await mmkvStorage.getItem(DISCOVER_TYPE_KEY); if (savedType) { setSelectedDiscoverType(savedType); } // Note: We'll load catalog and genre after discoverCatalogs are loaded } catch (error) { logger.error('Failed to load saved discover settings:', error); } }; loadSavedDiscoverSettings(); }, []); // Save discover settings when they change const saveDiscoverSettings = useCallback(async (type: string, catalog: DiscoverCatalog | null, genre: string | null) => { try { // Save type await mmkvStorage.setItem(DISCOVER_TYPE_KEY, type); // Save catalog if available if (catalog) { const catalogData = { addonId: catalog.addonId, catalogId: catalog.catalogId, type: catalog.type, }; await mmkvStorage.setItem(DISCOVER_CATALOG_KEY, JSON.stringify(catalogData)); } // Save genre if (genre) { await mmkvStorage.setItem(DISCOVER_GENRE_KEY, genre); } else { await mmkvStorage.removeItem(DISCOVER_GENRE_KEY); } } catch (error) { logger.error('Failed to save discover settings:', error); } }, []); // Load saved catalog and genre after discoverCatalogs are loaded useEffect(() => { const loadSavedCatalogAndGenre = async () => { if (discoverCatalogs.length === 0) return; try { // Load saved catalog const savedCatalogData = await mmkvStorage.getItem(DISCOVER_CATALOG_KEY); if (savedCatalogData) { try { const parsedCatalog = JSON.parse(savedCatalogData); const foundCatalog = discoverCatalogs.find(catalog => catalog.addonId === parsedCatalog.addonId && catalog.catalogId === parsedCatalog.catalogId && catalog.type === parsedCatalog.type ); if (foundCatalog) { setSelectedCatalog(foundCatalog); // Load saved genre const savedGenre = await mmkvStorage.getItem(DISCOVER_GENRE_KEY); if (savedGenre && foundCatalog.genres.includes(savedGenre)) { setSelectedDiscoverGenre(savedGenre); } else if (foundCatalog.genres.length > 0) { // Set first genre as default if saved genre not available setSelectedDiscoverGenre(foundCatalog.genres[0]); } return; } } catch (parseError) { logger.error('Failed to parse saved catalog data:', parseError); } } // Default behavior if no saved catalog found if (discoverCatalogs.length > 0) { const firstCatalog = discoverCatalogs[0]; setSelectedCatalog(firstCatalog); if (firstCatalog.genres.length > 0) { setSelectedDiscoverGenre(firstCatalog.genres[0]); } } } catch (error) { logger.error('Failed to load saved catalog and genre:', error); } }; loadSavedCatalogAndGenre(); }, [discoverCatalogs]); useEffect(() => { const focusSubscription = DeviceEventEmitter.addListener('FOCUS_SEARCH_INPUT', () => { // Optional: Reset search state if user double taps while on search if (query.length === 0) { setResults({ byAddon: [], allResults: [] }); setSearched(false); setShowRecent(true); } // Use a small timeout to ensure the UI is ready setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); } }, 120); }); return () => focusSubscription.remove(); }, [query]); // Update isSaved and isWatched when selectedItem changes useEffect(() => { if (!selectedItem) return; (async () => { const items = await catalogService.getLibraryItems(); const found = items.find((libItem: any) => libItem.id === selectedItem.id && libItem.type === selectedItem.type); setIsSaved(!!found); const val = await mmkvStorage.getItem(`watched:${selectedItem.type}:${selectedItem.id}`); setIsWatched(val === 'true'); })(); }, [selectedItem]); const handleShowMore = () => { if (pendingDiscoverResults.length === 0) return; const batchSize = 50; const nextBatch = pendingDiscoverResults.slice(0, batchSize); const remaining = pendingDiscoverResults.slice(batchSize); setDiscoverResults(prev => [...prev, ...nextBatch]); setPendingDiscoverResults(remaining); }; // Load discover catalogs on mount useEffect(() => { const loadDiscoverCatalogs = async () => { try { const filters = await catalogService.getDiscoverFilters(); if (isMounted.current) { const allCatalogs: DiscoverCatalog[] = []; for (const [type, catalogs] of Object.entries(filters.catalogsByType)) { for (const catalog of catalogs) { allCatalogs.push({ ...catalog, type }); } } setDiscoverCatalogs(allCatalogs); setDiscoverInitialized(true); } } catch (error) { logger.error('Failed to load discover catalogs:', error); if (isMounted.current) setDiscoverInitialized(true); } }; loadDiscoverCatalogs(); }, []); // Fetch discover content when catalog or genre changes useEffect(() => { if (!discoverInitialized || !selectedCatalog || query.trim().length > 0) return; const fetchDiscoverContent = async () => { if (!isMounted.current) return; setDiscoverLoading(true); setPage(1); setHasMore(true); setPendingDiscoverResults([]); try { const results = await catalogService.discoverContentFromCatalog( selectedCatalog.addonId, selectedCatalog.catalogId, selectedCatalog.type, selectedDiscoverGenre || undefined, 1 ); if (isMounted.current) { const seen = new Set(); const uniqueResults = results.filter(item => { const key = `${item.type}:${item.id}`; if (seen.has(key)) return false; seen.add(key); return true; }); const MAX_INITIAL_ITEMS = 100; if (uniqueResults.length > MAX_INITIAL_ITEMS) { setDiscoverResults(uniqueResults.slice(0, MAX_INITIAL_ITEMS)); setPendingDiscoverResults(uniqueResults.slice(MAX_INITIAL_ITEMS)); setHasMore(true); } else { setDiscoverResults(uniqueResults); setPendingDiscoverResults([]); setHasMore(uniqueResults.length >= 20); } } } catch (error) { logger.error('Failed to fetch discover content:', error); if (isMounted.current) setDiscoverResults([]); } finally { if (isMounted.current) setDiscoverLoading(false); } }; fetchDiscoverContent(); }, [discoverInitialized, selectedCatalog, selectedDiscoverGenre, query]); // Load more content for pagination const loadMoreDiscoverContent = async () => { if (!hasMore || loadingMore || discoverLoading || !selectedCatalog || pendingDiscoverResults.length > 0) return; setLoadingMore(true); const nextPage = page + 1; try { const moreResults = await catalogService.discoverContentFromCatalog( selectedCatalog.addonId, selectedCatalog.catalogId, selectedCatalog.type, selectedDiscoverGenre || undefined, nextPage ); if (isMounted.current) { if (moreResults.length > 0) { setDiscoverResults(prev => { const existingIds = new Set(prev.map(item => `${item.type}:${item.id}`)); const newUniqueResults = moreResults.filter(item => { const key = `${item.type}:${item.id}`; return !existingIds.has(key); }); if (newUniqueResults.length === 0) { setHasMore(false); return prev; } return [...prev, ...newUniqueResults]; }); setPage(nextPage); } else { setHasMore(false); } } } catch (error) { logger.error('Failed to load more discover content:', error); setHasMore(false); } finally { if (isMounted.current) setLoadingMore(false); } }; // Force consistent status bar settings useEffect(() => { const applyStatusBarConfig = () => { StatusBar.setBarStyle('light-content'); if (Platform.OS === 'android') { StatusBar.setTranslucent(true); StatusBar.setBackgroundColor('transparent'); } }; applyStatusBarConfig(); const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); return unsubscribe; }, [navigation]); React.useLayoutEffect(() => { navigation.setOptions({ headerShown: false }); }, [navigation]); useEffect(() => { loadRecentSearches(); return () => { debouncedSearch.cancel(); }; }, []); const animatedSearchBarStyle = useAnimatedStyle(() => ({ width: searchBarWidth.value, opacity: 1, })); const handleSearchFocus = () => { searchBarWidth.value = withTiming(width - 80); backButtonOpacity.value = withTiming(1); }; const handleSearchBlur = () => { if (!query) { searchBarWidth.value = withTiming(width - 32); backButtonOpacity.value = withTiming(0); } }; const handleBackPress = () => { Keyboard.dismiss(); if (query) { setQuery(''); setResults({ byAddon: [], allResults: [] }); setSearched(false); setShowRecent(true); loadRecentSearches(); } else { if (Platform.OS === 'android') { setTimeout(() => navigation.goBack(), 100); } else { navigation.goBack(); } } }; const loadRecentSearches = async () => { try { const savedSearches = await mmkvStorage.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); mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); return newRecentSearches; }); } catch (error) { logger.error('Failed to save recent search:', error); } }; const debouncedSearch = useMemo(() => { return debounce(async (searchQuery: string) => { liveSearchHandle.current?.cancel(); liveSearchHandle.current = null; performLiveSearch(searchQuery); }, 800); }, []); useFocusEffect( useCallback(() => { isMounted.current = true; // Check for route query param if (route.params?.query && route.params.query !== query) { setQuery(route.params.query); // The query effect will trigger debouncedSearch automatically } return () => { isMounted.current = false; if (liveSearchHandle.current) { liveSearchHandle.current.cancel(); liveSearchHandle.current = null; } debouncedSearch.cancel(); }; }, [debouncedSearch, route.params?.query]) ); const performLiveSearch = async (searchQuery: string) => { if (!isMounted.current) return; if (!searchQuery || searchQuery.trim().length === 0) { setResults({ byAddon: [], allResults: [] }); setSearching(false); return; } setSearching(true); setResults({ byAddon: [], allResults: [] }); addonOrderRankRef.current = {}; try { if (liveSearchHandle.current) liveSearchHandle.current.cancel(); const addons = await catalogService.getAllAddons(); const rank: Record = {}; let rankCounter = 0; rank['com.linvo.cinemeta'] = rankCounter++; addons.forEach(addon => { if (addon.id !== 'com.linvo.cinemeta') rank[addon.id] = rankCounter++; }); addonOrderRankRef.current = rank; const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => { if (!isMounted.current) return; setResults(prev => { if (!isMounted.current) return prev; // Use catalogIndex from the section for deterministic ordering. // Falls back to addonOrderRankRef for legacy single-catalog sections. const getRank = (section: AddonSearchResults) => { if (section.catalogIndex !== undefined) return section.catalogIndex; if (addonOrderRankRef.current[section.addonId] !== undefined) return addonOrderRankRef.current[section.addonId] * 1000; const baseAddonId = section.addonId.includes('||') ? section.addonId.split('||')[0] : section.addonId; return (addonOrderRankRef.current[baseAddonId] ?? Number.MAX_SAFE_INTEGER - 1) * 1000 + 500; }; const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId); if (existingIndex >= 0) { const copy = prev.byAddon.slice(); copy[existingIndex] = section; return { byAddon: copy, allResults: prev.allResults }; } const insertRank = getRank(section); let insertAt = prev.byAddon.length; for (let i = 0; i < prev.byAddon.length; i++) { if (getRank(prev.byAddon[i]) > insertRank) { insertAt = i; break; } } const nextByAddon = [ ...prev.byAddon.slice(0, insertAt), section, ...prev.byAddon.slice(insertAt) ]; if (prev.byAddon.length === 0) setSearching(false); return { byAddon: nextByAddon, allResults: prev.allResults }; }); try { await saveRecentSearch(searchQuery); } catch { } }); liveSearchHandle.current = handle; await handle.done; if (isMounted.current) setSearching(false); } catch (error) { if (isMounted.current) { console.error('Live search error:', error); setSearching(false); } } }; useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; loadRecentSearches(); return; } 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) { setSearching(false); setSearched(false); setShowRecent(false); setResults({ byAddon: [], allResults: [] }); } else { debouncedSearch.cancel(); liveSearchHandle.current?.cancel(); liveSearchHandle.current = null; setResults({ byAddon: [], allResults: [] }); setSearched(false); setSearching(false); setShowRecent(true); loadRecentSearches(); } return () => { debouncedSearch.cancel(); }; }, [query]); const handleClearSearch = () => { setQuery(''); liveSearchHandle.current?.cancel(); liveSearchHandle.current = null; setResults({ byAddon: [], allResults: [] }); setSearched(false); setShowRecent(true); loadRecentSearches(); inputRef.current?.focus(); }; const renderRecentSearches = () => { if (!showRecent || recentSearches.length === 0) return null; return ( {t('search.recent_searches')} {recentSearches.map((search, index) => ( { setQuery(search); Keyboard.dismiss(); }} > {search} { const newRecentSearches = [...recentSearches]; newRecentSearches.splice(index, 1); setRecentSearches(newRecentSearches); mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} style={styles.recentSearchDeleteButton} > ))} ); }; const availableGenres = useMemo(() => selectedCatalog?.genres || [], [selectedCatalog]); const availableTypes = useMemo(() => [...new Set(discoverCatalogs.map(c => c.type))], [discoverCatalogs]); const filteredCatalogs = useMemo(() => discoverCatalogs.filter(c => c.type === selectedDiscoverType), [discoverCatalogs, selectedDiscoverType]); const handleTypeSelect = (type: string) => { setSelectedDiscoverType(type); // Save type setting saveDiscoverSettings(type, selectedCatalog, selectedDiscoverGenre); const catalogsForType = discoverCatalogs.filter(c => c.type === type); // Try to find the same catalog in the new type let newCatalog = null; if (selectedCatalog) { newCatalog = catalogsForType.find(c => c.addonId === selectedCatalog.addonId && c.catalogId === selectedCatalog.catalogId ); } // Fallback to first catalog if not found if (!newCatalog && catalogsForType.length > 0) { newCatalog = catalogsForType[0]; } if (newCatalog) { setSelectedCatalog(newCatalog); // Try to preserve genre if (selectedDiscoverGenre && newCatalog.genres.includes(selectedDiscoverGenre)) { // Keep current genre - already saved } else if (newCatalog.genres.length > 0) { // Fallback to first genre if current not available const firstGenre = newCatalog.genres[0]; setSelectedDiscoverGenre(firstGenre); saveDiscoverSettings(type, newCatalog, firstGenre); } else { setSelectedDiscoverGenre(null); saveDiscoverSettings(type, newCatalog, null); } } else { setSelectedCatalog(null); setSelectedDiscoverGenre(null); saveDiscoverSettings(type, null, null); } typeSheetRef.current?.dismiss(); }; const handleCatalogSelect = (catalog: DiscoverCatalog) => { setSelectedCatalog(catalog); setSelectedDiscoverGenre(null); // Save catalog setting saveDiscoverSettings(selectedDiscoverType, catalog, null); catalogSheetRef.current?.dismiss(); }; const handleGenreSelect = (genre: string | null) => { setSelectedDiscoverGenre(genre); // Save genre setting saveDiscoverSettings(selectedDiscoverType, selectedCatalog, genre); genreSheetRef.current?.dismiss(); }; const hasResultsToShow = useMemo(() => results.byAddon.length > 0, [results]); // Item press handlers for AddonSection const handleItemPress = useCallback((item: StreamingContent) => { navigation.navigate('Metadata', { id: item.id, type: item.type, addonId: item.addonId }); }, [navigation]); const handleItemLongPress = useCallback((item: StreamingContent) => { setSelectedItem(item); setMenuVisible(true); }, []); // Set up listeners useEffect(() => { const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', () => { }); const librarySub = catalogService.subscribeToLibraryUpdates(() => { }); return () => { watchedSub.remove(); librarySub(); }; }, []); return ( {query.length > 0 && ( )} {searching && results.byAddon.length === 0 ? ( ) : searched && !hasResultsToShow && !searching ? ( {t('search.no_results')} {t('search.try_keywords')} ) : ( { if (query.trim().length > 0 || !settings.showDiscover || pendingDiscoverResults.length > 0) return; const { layoutMeasurement, contentOffset, contentSize } = nativeEvent; const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 500; if (isCloseToBottom) loadMoreDiscoverContent(); }} > {!query.trim() && renderRecentSearches()} {!query.trim() && settings.showDiscover && ( )} {results.byAddon.map((addonGroup, addonIndex) => ( ))} )} {/* DropUpMenu */} {selectedItem && ( setMenuVisible(false)} item={selectedItem} isSaved={isSaved} isWatched={isWatched} onOptionSelect={async (option: string) => { if (!selectedItem) return; switch (option) { case 'share': { let url = ''; if (selectedItem.type === 'movie') { url = `https://www.imdb.com/title/${selectedItem.id}`; } else { url = `https://www.imdb.com/title/${selectedItem.id}`; } try { await Share.share({ message: `Check out ${selectedItem.name}: ${url}`, url }); } catch (e) { } break; } case 'library': if (isSaved) { await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); showInfo(t('library.removed_from_library'), t('library.item_removed')); } else { await catalogService.addToLibrary(selectedItem); showSuccess(t('library.added_to_library'), t('library.item_added')); } setIsSaved(!isSaved); break; case 'watched': { const newWatchedState = !isWatched; await mmkvStorage.setItem(`watched:${selectedItem.type}:${selectedItem.id}`, newWatchedState ? 'true' : 'false'); setIsWatched(newWatchedState); showInfo( newWatchedState ? t('library.marked_watched') : t('library.marked_unwatched'), newWatchedState ? t('library.item_marked_watched') : t('library.item_marked_unwatched') ); DeviceEventEmitter.emit('watchedStatusChanged'); break; } case 'trakt-watchlist': if (isInWatchlist(selectedItem.id, selectedItem.type as 'movie' | 'show')) { await removeFromWatchlist(selectedItem.id, selectedItem.type as 'movie' | 'show'); showInfo(t('library.removed_from_watchlist'), t('library.removed_from_watchlist_desc')); } else { await addToWatchlist(selectedItem.id, selectedItem.type as 'movie' | 'show'); showSuccess(t('library.added_to_watchlist'), t('library.added_to_watchlist_desc')); } break; case 'trakt-collection': if (isInCollection(selectedItem.id, selectedItem.type as 'movie' | 'show')) { await removeFromCollection(selectedItem.id, selectedItem.type as 'movie' | 'show'); showInfo(t('library.removed_from_collection'), t('library.removed_from_collection_desc')); } else { await addToCollection(selectedItem.id, selectedItem.type as 'movie' | 'show'); showSuccess(t('library.added_to_collection'), t('library.added_to_collection_desc')); } break; } setMenuVisible(false); }} /> )} {/* Bottom Sheets */} ); }; export default SearchScreen;