From 56df30a4da7b36ed7f52394c092ae75c1fe92772 Mon Sep 17 00:00:00 2001 From: AdityasahuX07 Date: Tue, 6 Jan 2026 17:02:38 +0530 Subject: [PATCH 1/5] Implement focus event listener for Search tab Added a focus event listener to handle when the Search tab is pressed while already on the Search screen, focusing the input and clearing previous results. --- src/screens/SearchScreen.tsx | 146 ++++++++++++++++++++++------------- 1 file changed, 93 insertions(+), 53 deletions(-) diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 9c37096..9bff47e 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -130,11 +130,14 @@ const SearchScreen = () => { const catalogSnapPoints = useMemo(() => ['50%'], []); const genreSnapPoints = useMemo(() => ['50%'], []); - // Scroll to top handler const scrollToTop = useCallback(() => { scrollViewRef.current?.scrollTo({ y: 0, animated: true }); }, []); + const handleHeaderPress = useCallback(() => { + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + }, []); + useScrollToTop('Search', scrollToTop); useEffect(() => { @@ -144,6 +147,36 @@ const SearchScreen = () => { }; }, []); + // NEW: Add focus event listener for when Search tab is pressed while already on Search screen + useEffect(() => { + const focusSubscription = DeviceEventEmitter.addListener( + 'FOCUS_SEARCH_INPUT', + () => { + console.log('Search tab pressed while on Search screen - focusing input'); + + // Clear any search results to show a fresh state + if (query.length === 0) { + setResults({ byAddon: [], allResults: [] }); + setSearched(false); + setShowRecent(true); + loadRecentSearches(); + } + + // Focus the input after a small delay + setTimeout(() => { + if (inputRef.current && isMounted.current) { + console.log('Focusing search input'); + inputRef.current.focus(); + } + }, 100); + } + ); + + return () => { + focusSubscription.remove(); + }; + }, [query]); + const handleShowMore = () => { if (pendingDiscoverResults.length === 0) return; @@ -906,10 +939,10 @@ const SearchScreen = () => { isGrid && styles.discoverGridItem ]} onPress={() => { - navigation.navigate('Metadata', { - id: item.id, + navigation.navigate('Metadata', { + id: item.id, type: item.type, - addonId: item.addonId + addonId: item.addonId }); }} onLongPress={() => { @@ -1147,60 +1180,67 @@ const SearchScreen = () => { /> {/* ScreenHeader Component */} - - {/* Search Bar */} - - + + {/* Search Bar */} + - - - {query.length > 0 && ( - - - - )} + + + + {query.length > 0 && ( + + + + )} + - - + + {/* Content Container */} From 066bf6f15dc4aef1017dcbf4b10b5397d4429741 Mon Sep 17 00:00:00 2001 From: AdityasahuX07 Date: Tue, 6 Jan 2026 17:08:42 +0530 Subject: [PATCH 2/5] Enhance Search tab behavior with event emitter Added DeviceEventEmitter to handle search input focus on tab press for Search tab. --- src/navigation/AppNavigator.tsx | 81 ++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 11dcba1..5c28d45 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useMemo, useState } from 'react'; import { NavigationContainer, DefaultTheme as NavigationDefaultTheme, DarkTheme as NavigationDarkTheme, Theme, NavigationProp } from '@react-navigation/native'; import { createNativeStackNavigator, NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack'; import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; -import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState, Easing, Dimensions } from 'react-native'; +import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState, Easing, Dimensions, DeviceEventEmitter } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; import { PaperProvider, MD3DarkTheme, MD3LightTheme, adaptNavigationTheme } from 'react-native-paper'; import type { MD3Theme } from 'react-native-paper'; @@ -71,6 +71,7 @@ import BackupScreen from '../screens/BackupScreen'; import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen'; import ContributorsScreen from '../screens/ContributorsScreen'; import DebridIntegrationScreen from '../screens/DebridIntegrationScreen'; + import { ContentDiscoverySettingsScreen, AppearanceSettingsScreen, @@ -689,15 +690,32 @@ const MainTabs = () => { const isFocused = props.state.index === index; const onPress = () => { + // Create a synthetic event object we can modify + const syntheticEvent = { + type: 'tabPress', + target: route.key, + canPreventDefault: true, + defaultPrevented: false, + preventDefault: () => { + syntheticEvent.defaultPrevented = true; + } + }; + const event = props.navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true, }); + if (isFocused) { - // Same tab pressed - emit scroll to top - emitScrollToTop(route.name); - } else if (!event.defaultPrevented) { + if (route.name === 'Search') { + // Send the signal to SearchScreen.tsx to focus input + DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); + syntheticEvent.preventDefault(); // Prevent scroll-to-top + } else { + emitScrollToTop(route.name); + } + } else if (!syntheticEvent.defaultPrevented && !event.defaultPrevented) { props.navigation.navigate(route.name); } }; @@ -806,6 +824,14 @@ const MainTabs = () => { const isFocused = props.state.index === index; const onPress = () => { + // For Search tab, we need to handle this specially + if (isFocused && route.name === 'Search') { + // Focus search input instead of scrolling to top + DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); + // Return early to prevent navigation and scroll-to-top + return; + } + const event = props.navigation.emit({ type: 'tabPress', target: route.key, @@ -813,7 +839,7 @@ const MainTabs = () => { }); if (isFocused) { - // Same tab pressed - emit scroll to top + // For other tabs, scroll to top emitScrollToTop(route.name); } else if (!event.defaultPrevented) { props.navigation.navigate(route.name); @@ -952,7 +978,10 @@ const MainTabs = () => { listeners={({ navigation }: { navigation: any }) => ({ tabPress: (e: any) => { if (navigation.isFocused()) { - emitScrollToTop('Search'); + // Focus search input instead of scrolling to top + DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); + // Don't try to access preventDefault on native iOS tabs + // Just emit the event and let SearchScreen handle it } }, })} @@ -1059,6 +1088,13 @@ const MainTabs = () => { ), freezeOnBlur: true, }} + listeners={({ navigation }: any) => ({ + tabPress: (e: any) => { + if (navigation.isFocused()) { + emitScrollToTop('Home'); + } + }, + })} /> { ), }} + listeners={({ navigation }: any) => ({ + tabPress: (e: any) => { + if (navigation.isFocused()) { + emitScrollToTop('Library'); + } + }, + })} /> { ), }} + listeners={({ navigation }: any) => ({ + tabPress: (e: any) => { + if (navigation.isFocused()) { + // Focus search input instead of scrolling to top + DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); + // Simply return to prevent navigation + return; + } + }, + })} /> {appSettings?.enableDownloads !== false && ( { ), }} + listeners={({ navigation }: any) => ({ + tabPress: (e: any) => { + if (navigation.isFocused()) { + emitScrollToTop('Downloads'); + } + }, + })} /> )} { ), }} + listeners={({ navigation }: any) => ({ + tabPress: (e: any) => { + if (navigation.isFocused()) { + emitScrollToTop('Settings'); + } + }, + })} /> @@ -1771,4 +1838,4 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack ); -export default AppNavigator; \ No newline at end of file +export default AppNavigator; From 3b210b06d522ae548ecb762c8ff2c1b27bacf76a Mon Sep 17 00:00:00 2001 From: AdityasahuX07 Date: Wed, 7 Jan 2026 17:18:37 +0530 Subject: [PATCH 3/5] Update AppNavigator.tsx --- src/navigation/AppNavigator.tsx | 175 ++++++++++++++++---------------- 1 file changed, 87 insertions(+), 88 deletions(-) diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 5c28d45..1731587 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -17,6 +17,7 @@ import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-cont import { useTheme } from '../contexts/ThemeContext'; import { PostHogProvider } from 'posthog-react-native'; import { ScrollToTopProvider, useScrollToTopEmitter } from '../contexts/ScrollToTopContext'; +import { useTranslation } from 'react-i18next'; // Optional iOS Glass effect (expo-glass-effect) with safe fallback let GlassViewComp: any = null; @@ -71,7 +72,6 @@ import BackupScreen from '../screens/BackupScreen'; import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen'; import ContributorsScreen from '../screens/ContributorsScreen'; import DebridIntegrationScreen from '../screens/DebridIntegrationScreen'; - import { ContentDiscoverySettingsScreen, AppearanceSettingsScreen, @@ -79,6 +79,7 @@ import { PlaybackSettingsScreen, AboutSettingsScreen, DeveloperSettingsScreen, + LegalScreen, } from '../screens/settings'; @@ -217,6 +218,7 @@ export type RootStackParamList = { PlaybackSettings: undefined; AboutSettings: undefined; DeveloperSettings: undefined; + Legal: undefined; }; @@ -471,7 +473,6 @@ const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material' // Update the TabScreenWrapper component with fixed layout dimensions const TabScreenWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [dimensions, setDimensions] = useState(Dimensions.get('window')); useEffect(() => { const subscription = Dimensions.addEventListener('change', ({ window }) => { @@ -546,12 +547,14 @@ const WrappedScreen: React.FC<{ Screen: React.ComponentType }> = ({ Screen // Tab Navigator const MainTabs = () => { + const { t } = useTranslation(); const { currentTheme } = useTheme(); const { settings } = require('../hooks/useSettings'); const { useSettings: useSettingsHook } = require('../hooks/useSettings'); const { settings: appSettings } = useSettingsHook(); const [hasUpdateBadge, setHasUpdateBadge] = React.useState(false); const [dimensions, setDimensions] = useState(Dimensions.get('window')); + const lastTapRef = useRef>({}); useEffect(() => { const subscription = Dimensions.addEventListener('change', ({ window }) => { @@ -689,17 +692,16 @@ const MainTabs = () => { const isFocused = props.state.index === index; + const lastTapRef = useRef>({}); // Add this ref at the top of MainTabs component + const onPress = () => { - // Create a synthetic event object we can modify - const syntheticEvent = { - type: 'tabPress', - target: route.key, - canPreventDefault: true, - defaultPrevented: false, - preventDefault: () => { - syntheticEvent.defaultPrevented = true; - } - }; + const now = Date.now(); + const DOUBLE_TAP_DELAY = 300; + const lastTap = lastTapRef.current[route.name] || 0; + const isSearchDoubleTap = route.name === 'Search' && (now - lastTap) < DOUBLE_TAP_DELAY; + + // Update last tap time + lastTapRef.current[route.name] = now; const event = props.navigation.emit({ type: 'tabPress', @@ -708,14 +710,14 @@ const MainTabs = () => { }); if (isFocused) { - if (route.name === 'Search') { - // Send the signal to SearchScreen.tsx to focus input + // If double tap on Search -> Open Keyboard + if (isSearchDoubleTap) { DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); - syntheticEvent.preventDefault(); // Prevent scroll-to-top } else { + // Single tap on active tab -> Scroll to Top emitScrollToTop(route.name); } - } else if (!syntheticEvent.defaultPrevented && !event.defaultPrevented) { + } else if (!event.defaultPrevented) { props.navigation.navigate(route.name); } }; @@ -824,27 +826,29 @@ const MainTabs = () => { const isFocused = props.state.index === index; const onPress = () => { - // For Search tab, we need to handle this specially - if (isFocused && route.name === 'Search') { - // Focus search input instead of scrolling to top - DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); - // Return early to prevent navigation and scroll-to-top - return; - } + const now = Date.now(); + const DOUBLE_TAP_DELAY = 300; + const lastTap = lastTapRef.current[route.name] || 0; - const event = props.navigation.emit({ - type: 'tabPress', - target: route.key, - canPreventDefault: true, - }); + // DOUBLE TAP LOGIC: If search is pressed twice quickly + if (route.name === 'Search' && now - lastTap < DOUBLE_TAP_DELAY) { + DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); + } - if (isFocused) { - // For other tabs, scroll to top - emitScrollToTop(route.name); - } else if (!event.defaultPrevented) { - props.navigation.navigate(route.name); - } - }; + lastTapRef.current[route.name] = now; + + const event = props.navigation.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }); + + if (isFocused) { + emitScrollToTop(route.name); + } else if (!event.defaultPrevented) { + props.navigation.navigate(route.name); + } + }; let iconName: IconNameType = 'home'; let iconLibrary: 'material' | 'feather' | 'ionicons' = 'material'; @@ -941,7 +945,7 @@ const MainTabs = () => { name="Home" component={HomeScreen} options={{ - title: 'Home', + title: t('navigation.home'), tabBarIcon: () => ({ sfSymbol: 'house' }), freezeOnBlur: true, }} @@ -957,7 +961,7 @@ const MainTabs = () => { name="Library" component={LibraryScreen} options={{ - title: 'Library', + title: t('navigation.library'), tabBarIcon: () => ({ sfSymbol: 'heart' }), }} listeners={({ navigation }: { navigation: any }) => ({ @@ -972,16 +976,13 @@ const MainTabs = () => { name="Search" component={SearchScreen} options={{ - title: 'Search', + title: t('navigation.search'), tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }), }} listeners={({ navigation }: { navigation: any }) => ({ tabPress: (e: any) => { if (navigation.isFocused()) { - // Focus search input instead of scrolling to top - DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); - // Don't try to access preventDefault on native iOS tabs - // Just emit the event and let SearchScreen handle it + emitScrollToTop('Search'); } }, })} @@ -991,7 +992,7 @@ const MainTabs = () => { name="Downloads" component={DownloadsScreen} options={{ - title: 'Downloads', + title: t('navigation.downloads'), tabBarIcon: () => ({ sfSymbol: 'arrow.down.circle' }), }} listeners={({ navigation }: { navigation: any }) => ({ @@ -1007,7 +1008,7 @@ const MainTabs = () => { name="Settings" component={SettingsScreen} options={{ - title: 'Settings', + title: t('navigation.settings'), tabBarIcon: () => ({ sfSymbol: 'gear' }), }} listeners={({ navigation }: { navigation: any }) => ({ @@ -1082,92 +1083,75 @@ const MainTabs = () => { name="Home" component={HomeScreen} options={{ - tabBarLabel: 'Home', + tabBarLabel: t('navigation.home'), tabBarIcon: ({ color, size, focused }) => ( ), freezeOnBlur: true, }} - listeners={({ navigation }: any) => ({ - tabPress: (e: any) => { - if (navigation.isFocused()) { - emitScrollToTop('Home'); - } - }, - })} /> ( ), }} - listeners={({ navigation }: any) => ({ - tabPress: (e: any) => { - if (navigation.isFocused()) { - emitScrollToTop('Library'); - } - }, - })} /> ( - + ), - }} - listeners={({ navigation }: any) => ({ - tabPress: (e: any) => { - if (navigation.isFocused()) { - // Focus search input instead of scrolling to top - DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); - // Simply return to prevent navigation - return; - } + tabBarButton: (props) => { + const lastTap = useRef(0); + return ( + { + const now = Date.now(); + const DOUBLE_TAP_DELAY = 300; + + // Check for double tap + if (now - lastTap.current < DOUBLE_TAP_DELAY) { + DeviceEventEmitter.emit('FOCUS_SEARCH_INPUT'); + } else { + props.onPress?.(e); + } + lastTap.current = now; + }} + /> + ); }, - })} + }} /> {appSettings?.enableDownloads !== false && ( ( ), }} - listeners={({ navigation }: any) => ({ - tabPress: (e: any) => { - if (navigation.isFocused()) { - emitScrollToTop('Downloads'); - } - }, - })} /> )} ( ), }} - listeners={({ navigation }: any) => ({ - tabPress: (e: any) => { - if (navigation.isFocused()) { - emitScrollToTop('Settings'); - } - }, - })} /> @@ -1816,6 +1800,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + From cd1ed27f1ed14bc9d1dacc4ae0a4e9d66d5c325e Mon Sep 17 00:00:00 2001 From: AdityasahuX07 Date: Wed, 7 Jan 2026 17:19:19 +0530 Subject: [PATCH 4/5] Update print statement from 'Hello' to 'Goodbye' --- src/screens/SearchScreen.tsx | 1833 +++++----------------------------- 1 file changed, 241 insertions(+), 1592 deletions(-) diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 9bff47e..6383118 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -2,92 +2,60 @@ 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, - Modal, } from 'react-native'; -import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; -import { MaterialIcons, Feather } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { MaterialIcons } from '@expo/vector-icons'; import { catalogService, StreamingContent, GroupedSearchResults, AddonSearchResults } from '../services/catalogService'; -import FastImage from '@d11/react-native-fast-image'; import debounce from 'lodash/debounce'; import { DropUpMenu } from '../components/home/DropUpMenu'; import { DeviceEventEmitter, Share } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; 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'; import ScreenHeader from '../components/common/ScreenHeader'; import { useScrollToTop } from '../contexts/ScrollToTopContext'; -import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'; +import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { useSettings } from '../hooks/useSettings'; // Import extracted search components import { DiscoverCatalog, - BREAKPOINTS, - getDeviceType, isTablet, isLargeTablet, isTV, - TAB_BAR_HEIGHT, RECENT_SEARCHES_KEY, MAX_RECENT_SEARCHES, - PLACEHOLDER_POSTER, - HORIZONTAL_ITEM_WIDTH, - HORIZONTAL_POSTER_HEIGHT, - POSTER_WIDTH, - POSTER_HEIGHT, } from '../components/search/searchUtils'; -import { SearchSkeletonLoader } from '../components/search/SearchSkeletonLoader'; +import { searchStyles as styles } from '../components/search/searchStyles'; import { SearchAnimation } from '../components/search/SearchAnimation'; -import { SearchResultItem } from '../components/search/SearchResultItem'; -import { RecentSearches } from '../components/search/RecentSearches'; - -const { width, height } = Dimensions.get('window'); - -// Re-export for local use (backward compatibility) -const deviceType = getDeviceType(width); +import { AddonSection } from '../components/search/AddonSection'; +import { DiscoverSection } from '../components/search/DiscoverSection'; +import { DiscoverBottomSheets } from '../components/search/DiscoverBottomSheets'; +const { width } = Dimensions.get('window'); const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); -// Alias imported components for backward compatibility with existing code -const SkeletonLoader = SearchSkeletonLoader; -const SimpleSearchAnimation = SearchAnimation; - -const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; - const SearchScreen = () => { + const { t } = useTranslation(); const { settings } = useSettings(); const navigation = useNavigation>(); - const isDarkMode = true; const [query, setQuery] = useState(''); const [results, setResults] = useState({ byAddon: [], allResults: [] }); const [searching, setSearching] = useState(false); @@ -97,13 +65,9 @@ const SearchScreen = () => { const inputRef = useRef(null); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); - // Live search handle const liveSearchHandle = useRef<{ cancel: () => void; done: Promise } | null>(null); - // Addon installation order map for stable section ordering const addonOrderRankRef = useRef>({}); - // Track if this is the initial mount to prevent unnecessary operations const isInitialMount = useRef(true); - // Track mount status for async operations const isMounted = useRef(true); const scrollViewRef = useRef(null); @@ -112,79 +76,78 @@ const SearchScreen = () => { const [selectedCatalog, setSelectedCatalog] = useState(null); const [selectedDiscoverType, setSelectedDiscoverType] = useState<'movie' | 'series'>('movie'); const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState(null); - // Discover pagination state 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 and state + // Bottom sheet refs const typeSheetRef = useRef(null); const catalogSheetRef = useRef(null); const genreSheetRef = useRef(null); - const typeSnapPoints = useMemo(() => ['25%'], []); - const catalogSnapPoints = useMemo(() => ['50%'], []); - const genreSnapPoints = useMemo(() => ['50%'], []); + // 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 }); }, []); - const handleHeaderPress = useCallback(() => { - scrollViewRef.current?.scrollTo({ y: 0, animated: true }); - }, []); - useScrollToTop('Search', scrollToTop); useEffect(() => { isMounted.current = true; - return () => { - isMounted.current = false; - }; + return () => { isMounted.current = false; }; }, []); - // NEW: Add focus event listener for when Search tab is pressed while already on Search screen useEffect(() => { - const focusSubscription = DeviceEventEmitter.addListener( - 'FOCUS_SEARCH_INPUT', - () => { - console.log('Search tab pressed while on Search screen - focusing input'); - - // Clear any search results to show a fresh state - if (query.length === 0) { - setResults({ byAddon: [], allResults: [] }); - setSearched(false); - setShowRecent(true); - loadRecentSearches(); - } - - // Focus the input after a small delay - setTimeout(() => { - if (inputRef.current && isMounted.current) { - console.log('Focusing search input'); - inputRef.current.focus(); - } - }, 100); + 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); } - ); - return () => { - focusSubscription.remove(); - }; + // 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; - - // Show next batch of 300 items - const batchSize = 300; + const batchSize = 50; const nextBatch = pendingDiscoverResults.slice(0, batchSize); const remaining = pendingDiscoverResults.slice(batchSize); - setDiscoverResults(prev => [...prev, ...nextBatch]); setPendingDiscoverResults(remaining); }; @@ -195,21 +158,15 @@ const SearchScreen = () => { try { const filters = await catalogService.getDiscoverFilters(); if (isMounted.current) { - // Flatten catalogs from all types into a single array const allCatalogs: DiscoverCatalog[] = []; for (const [type, catalogs] of Object.entries(filters.catalogsByType)) { - // Only include movie and series types if (type === 'movie' || type === 'series') { for (const catalog of catalogs) { - allCatalogs.push({ - ...catalog, - type, - }); + allCatalogs.push({ ...catalog, type }); } } } setDiscoverCatalogs(allCatalogs); - // Auto-select first catalog if available if (allCatalogs.length > 0) { setSelectedCatalog(allCatalogs[0]); } @@ -217,9 +174,7 @@ const SearchScreen = () => { } } catch (error) { logger.error('Failed to load discover catalogs:', error); - if (isMounted.current) { - setDiscoverInitialized(true); - } + if (isMounted.current) setDiscoverInitialized(true); } }; loadDiscoverCatalogs(); @@ -232,7 +187,7 @@ const SearchScreen = () => { const fetchDiscoverContent = async () => { if (!isMounted.current) return; setDiscoverLoading(true); - setPage(1); // Reset page on new filter + setPage(1); setHasMore(true); setPendingDiscoverResults([]); try { @@ -241,28 +196,33 @@ const SearchScreen = () => { selectedCatalog.catalogId, selectedCatalog.type, selectedDiscoverGenre || undefined, - 1 // page 1 + 1 ); if (isMounted.current) { - if (results.length > 300) { - setDiscoverResults(results.slice(0, 300)); - setPendingDiscoverResults(results.slice(300)); + 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(results); + setDiscoverResults(uniqueResults); setPendingDiscoverResults([]); - setHasMore(results.length > 0); + setHasMore(uniqueResults.length >= 20); } } } catch (error) { logger.error('Failed to fetch discover content:', error); - if (isMounted.current) { - setDiscoverResults([]); - } + if (isMounted.current) setDiscoverResults([]); } finally { - if (isMounted.current) { - setDiscoverLoading(false); - } + if (isMounted.current) setDiscoverLoading(false); } }; @@ -287,12 +247,20 @@ const SearchScreen = () => { if (isMounted.current) { if (moreResults.length > 0) { - if (moreResults.length > 300) { - setDiscoverResults(prev => [...prev, ...moreResults.slice(0, 300)]); - setPendingDiscoverResults(moreResults.slice(300)); - } else { - setDiscoverResults(prev => [...prev, ...moreResults]); - } + 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); @@ -300,38 +268,12 @@ const SearchScreen = () => { } } catch (error) { logger.error('Failed to load more discover content:', error); + setHasMore(false); } finally { - if (isMounted.current) { - setLoadingMore(false); - } + if (isMounted.current) setLoadingMore(false); } }; - // 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 mmkvStorage.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 = () => { @@ -341,60 +283,32 @@ const SearchScreen = () => { StatusBar.setBackgroundColor('transparent'); } }; - applyStatusBarConfig(); - - // Re-apply on focus const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); return unsubscribe; }, [navigation]); React.useLayoutEffect(() => { - navigation.setOptions({ - headerShown: false, - }); + navigation.setOptions({ headerShown: false }); }, [navigation]); useEffect(() => { loadRecentSearches(); - - // Cleanup function to cancel pending searches on unmount - return () => { - debouncedSearch.cancel(); - }; + 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 animatedSearchBarStyle = useAnimatedStyle(() => ({ + width: searchBarWidth.value, + opacity: 1, + })); 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); } @@ -409,11 +323,8 @@ const SearchScreen = () => { setShowRecent(true); loadRecentSearches(); } else { - // Add a small delay to allow keyboard to dismiss smoothly before navigation if (Platform.OS === 'android') { - setTimeout(() => { - navigation.goBack(); - }, 100); + setTimeout(() => navigation.goBack(), 100); } else { navigation.goBack(); } @@ -423,9 +334,7 @@ const SearchScreen = () => { const loadRecentSearches = async () => { try { const savedSearches = await mmkvStorage.getItem(RECENT_SEARCHES_KEY); - if (savedSearches) { - setRecentSearches(JSON.parse(savedSearches)); - } + if (savedSearches) setRecentSearches(JSON.parse(savedSearches)); } catch (error) { logger.error('Failed to load recent searches:', error); } @@ -438,10 +347,7 @@ const SearchScreen = () => { searchQuery, ...prevSearches.filter(s => s !== searchQuery) ].slice(0, MAX_RECENT_SEARCHES); - - // Save to AsyncStorage mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); - return newRecentSearches; }); } catch (error) { @@ -449,23 +355,19 @@ const SearchScreen = () => { } }; - // Create a stable debounced search function using useMemo const debouncedSearch = useMemo(() => { return debounce(async (searchQuery: string) => { - // Cancel any in-flight live search liveSearchHandle.current?.cancel(); liveSearchHandle.current = null; performLiveSearch(searchQuery); }, 800); - }, []); // Empty dependency array - create once and never recreate + }, []); - // Track focus state to strictly prevent updates when blurred (fixes Telemetry crash) useFocusEffect( useCallback(() => { isMounted.current = true; return () => { isMounted.current = false; - // Cancel any active searches immediately on blur if (liveSearchHandle.current) { liveSearchHandle.current.cancel(); liveSearchHandle.current = null; @@ -475,11 +377,8 @@ const SearchScreen = () => { }, [debouncedSearch]) ); - // Live search implementation const performLiveSearch = async (searchQuery: string) => { - // strict guard: don't search if unmounted or blurred if (!isMounted.current) return; - if (!searchQuery || searchQuery.trim().length === 0) { setResults({ byAddon: [], allResults: [] }); setSearching(false); @@ -488,42 +387,26 @@ const SearchScreen = () => { setSearching(true); setResults({ byAddon: [], allResults: [] }); - // Reset order rank for new search addonOrderRankRef.current = {}; try { - if (liveSearchHandle.current) { - liveSearchHandle.current.cancel(); - } + if (liveSearchHandle.current) liveSearchHandle.current.cancel(); - // Pre-fetch addon list to establish a stable order rank const addons = await catalogService.getAllAddons(); - // ... (rank logic) ... const rank: Record = {}; let rankCounter = 0; - - // Cinemeta first rank['com.linvo.cinemeta'] = rankCounter++; - - // Then others addons.forEach(addon => { - if (addon.id !== 'com.linvo.cinemeta') { - rank[addon.id] = rankCounter++; - } + if (addon.id !== 'com.linvo.cinemeta') rank[addon.id] = rankCounter++; }); addonOrderRankRef.current = rank; const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => { - // Prevent updates if component is unmounted or blurred if (!isMounted.current) return; - // Append/update this addon section... setResults(prev => { - // ... (existing update logic) ... - if (!isMounted.current) return prev; // Extra guard inside setter - + if (!isMounted.current) return prev; const getRank = (id: string) => addonOrderRankRef.current[id] ?? Number.MAX_SAFE_INTEGER; - // ... (same logic as before) ... const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId); if (existingIndex >= 0) { @@ -532,7 +415,6 @@ const SearchScreen = () => { return { byAddon: copy, allResults: prev.allResults }; } - // Insert new section const insertRank = getRank(section.addonId); let insertAt = prev.byAddon.length; for (let i = 0; i < prev.byAddon.length; i++) { @@ -548,25 +430,16 @@ const SearchScreen = () => { ...prev.byAddon.slice(insertAt) ]; - // Hide loading overlay once first section arrives - if (prev.byAddon.length === 0) { - setSearching(false); - } - + if (prev.byAddon.length === 0) setSearching(false); return { byAddon: nextByAddon, allResults: prev.allResults }; }); - try { - await saveRecentSearch(searchQuery); - } catch { } + try { await saveRecentSearch(searchQuery); } catch { } }); liveSearchHandle.current = handle; await handle.done; - - if (isMounted.current) { - setSearching(false); - } + if (isMounted.current) setSearching(false); } catch (error) { if (isMounted.current) { console.error('Live search error:', error); @@ -574,8 +447,8 @@ const SearchScreen = () => { } } }; + useEffect(() => { - // Skip initial mount to prevent unnecessary operations if (isInitialMount.current) { isInitialMount.current = false; loadRecentSearches(); @@ -588,13 +461,11 @@ const SearchScreen = () => { 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({ byAddon: [], allResults: [] }); } else { - // Cancel any pending search when query is cleared debouncedSearch.cancel(); liveSearchHandle.current?.cancel(); liveSearchHandle.current = null; @@ -605,11 +476,8 @@ const SearchScreen = () => { loadRecentSearches(); } - // Cleanup function to cancel pending searches - return () => { - debouncedSearch.cancel(); - }; - }, [query]); // Removed debouncedSearch since it's now stable with useMemo + return () => { debouncedSearch.cancel(); }; + }, [query]); const handleClearSearch = () => { setQuery(''); @@ -626,30 +494,18 @@ const SearchScreen = () => { if (!showRecent || recentSearches.length === 0) return null; return ( - + - Recent Searches + {t('search.recent_searches')} {recentSearches.map((search, index) => ( { - setQuery(search); - Keyboard.dismiss(); - }} + onPress={() => { setQuery(search); Keyboard.dismiss(); }} > - - - {search} - + + {search} { const newRecentSearches = [...recentSearches]; @@ -668,30 +524,36 @@ const SearchScreen = () => { ); }; - // Get available genres for the selected catalog - const availableGenres = useMemo(() => { - if (!selectedCatalog) return []; - return selectedCatalog.genres; - }, [selectedCatalog]); + const availableGenres = useMemo(() => selectedCatalog?.genres || [], [selectedCatalog]); + const filteredCatalogs = useMemo(() => discoverCatalogs.filter(c => c.type === selectedDiscoverType), [discoverCatalogs, selectedDiscoverType]); - // Get catalogs filtered by selected type - const filteredCatalogs = useMemo(() => { - return discoverCatalogs.filter(catalog => catalog.type === selectedDiscoverType); - }, [discoverCatalogs, selectedDiscoverType]); - - // Handle type selection const handleTypeSelect = (type: 'movie' | 'series') => { setSelectedDiscoverType(type); - - // Auto-select first catalog for the new type const catalogsForType = discoverCatalogs.filter(c => c.type === type); - if (catalogsForType.length > 0) { - const firstCatalog = catalogsForType[0]; - setSelectedCatalog(firstCatalog); - // Auto-select first genre if available - if (firstCatalog.genres.length > 0) { - setSelectedDiscoverGenre(firstCatalog.genres[0]); + // 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 + } else if (newCatalog.genres.length > 0) { + // Fallback to first genre if current not available + setSelectedDiscoverGenre(newCatalog.genres[0]); } else { setSelectedDiscoverGenre(null); } @@ -699,589 +561,79 @@ const SearchScreen = () => { setSelectedCatalog(null); setSelectedDiscoverGenre(null); } - typeSheetRef.current?.dismiss(); }; - // Handle catalog selection const handleCatalogSelect = (catalog: DiscoverCatalog) => { setSelectedCatalog(catalog); - setSelectedDiscoverGenre(null); // Reset genre when catalog changes + setSelectedDiscoverGenre(null); catalogSheetRef.current?.dismiss(); }; - // Handle genre selection const handleGenreSelect = (genre: string | null) => { setSelectedDiscoverGenre(genre); genreSheetRef.current?.dismiss(); }; - // Render backdrop for bottom sheets - const renderBackdrop = useCallback( - (props: any) => ( - - ), - [] - ); + const hasResultsToShow = useMemo(() => results.byAddon.length > 0, [results]); - // Render discover section with catalog and genre selector chips - const renderDiscoverSection = () => { - if (query.trim().length > 0) return null; + // Item press handlers for AddonSection + const handleItemPress = useCallback((item: StreamingContent) => { + navigation.navigate('Metadata', { id: item.id, type: item.type, addonId: item.addonId }); + }, [navigation]); - return ( - - {/* Section Header */} - - - Discover - - + const handleItemLongPress = useCallback((item: StreamingContent) => { + setSelectedItem(item); + setMenuVisible(true); + }, []); - {/* Filter Chips Row */} - {/* Filter Chips Row */} - - {/* Type Selector Chip (Movie/TV Show) */} - typeSheetRef.current?.present()} - > - - {selectedDiscoverType === 'movie' ? 'Movies' : 'TV Shows'} - - - - - {/* Catalog Selector Chip */} - catalogSheetRef.current?.present()} - > - - {selectedCatalog ? selectedCatalog.catalogName : 'Select Catalog'} - - - - - {/* Genre Selector Chip - only show if catalog has genres */} - {availableGenres.length > 0 && ( - genreSheetRef.current?.present()} - > - - {selectedDiscoverGenre || 'All Genres'} - - - - )} - - - {/* Selected filters summary */} - {selectedCatalog && ( - - - {selectedCatalog.addonName} • {selectedCatalog.type === 'movie' ? 'Movies' : 'TV Shows'} - {selectedDiscoverGenre ? ` • ${selectedDiscoverGenre}` : ''} - - - )} - - {/* Discover Results */} - {discoverLoading ? ( - - - - Discovering content... - - - ) : discoverResults.length > 0 ? ( - `discover-${item.id}-${index}`} - numColumns={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3} - key={isTV ? 'tv-6' : isLargeTablet ? 'ltab-5' : isTablet ? 'tab-4' : 'phone-3'} - columnWrapperStyle={styles.discoverGridRow} - contentContainerStyle={styles.discoverGridContent} - renderItem={({ item, index }) => ( - - )} - initialNumToRender={9} - maxToRenderPerBatch={6} - windowSize={5} - removeClippedSubviews={true} - scrollEnabled={false} - ListFooterComponent={ - pendingDiscoverResults.length > 0 ? ( - - - Show More ({pendingDiscoverResults.length}) - - - - ) : loadingMore ? ( - - - - ) : null - } - /> - ) : discoverInitialized && !discoverLoading && selectedCatalog ? ( - - - - No content found - - - Try a different genre or catalog - - - ) : !selectedCatalog && discoverInitialized ? ( - - - - Select a catalog to discover - - - Tap the catalog chip above to get started - - - ) : null} - - ); - }; - - const SearchResultItem = ({ item, index, navigation, setSelectedItem, setMenuVisible, currentTheme, isGrid = false }: { - item: StreamingContent; - index: number; - navigation: any; - setSelectedItem: (item: StreamingContent) => void; - setMenuVisible: (visible: boolean) => void; - currentTheme: any; - isGrid?: boolean; - }) => { - const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary); - const [watched, setWatched] = React.useState(false); - - // Calculate dimensions based on poster shape - const { itemWidth, aspectRatio } = useMemo(() => { - const shape = item.posterShape || 'poster'; - const baseHeight = HORIZONTAL_POSTER_HEIGHT; - - let w = HORIZONTAL_ITEM_WIDTH; - let r = 2 / 3; - - if (isGrid) { - // Grid Calculation: (Window Width - Padding) / Columns - // Padding: 16 (left) + 16 (right) = 32 - // Gap: 12 (between items) * (columns - 1) - // Ensure minimum 3 columns on all devices - const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3; - const totalPadding = 32; - const totalGap = 12 * (Math.max(3, columns) - 1); - const availableWidth = width - totalPadding - totalGap; - w = availableWidth / Math.max(3, columns); - } else { - if (shape === 'landscape') { - r = 16 / 9; - w = baseHeight * r; - } else if (shape === 'square') { - r = 1; - w = baseHeight; - } - } - return { itemWidth: w, aspectRatio: r }; - }, [item.posterShape, isGrid]); - - React.useEffect(() => { - const updateWatched = () => { - mmkvStorage.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, - addonId: item.addonId - }); - }} - onLongPress={() => { - setSelectedItem(item); - setMenuVisible(true); - // Do NOT toggle refreshFlag here - }} - delayLongPress={300} - activeOpacity={0.7} - > - - - {/* Bookmark and watched icons top right, bookmark to the left of watched */} - {inLibrary && ( - - - - )} - {watched && ( - - - - )} - {/* Rating removed per user request */} - - - {item.name} - - {item.year && ( - - {item.year} - - )} - - ); - }; - - const hasResultsToShow = useMemo(() => { - return results.byAddon.length > 0; - }, [results]); - - // Memoized addon section to prevent re-rendering unchanged sections - const AddonSection = React.memo(({ - addonGroup, - addonIndex - }: { - addonGroup: AddonSearchResults; - addonIndex: number; - }) => { - const movieResults = useMemo(() => - addonGroup.results.filter(item => item.type === 'movie'), - [addonGroup.results] - ); - const seriesResults = useMemo(() => - addonGroup.results.filter(item => item.type === 'series'), - [addonGroup.results] - ); - const otherResults = useMemo(() => - addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'), - [addonGroup.results] - ); - - return ( - - {/* Addon Header */} - - - {addonGroup.addonName} - - - - {addonGroup.results.length} - - - - - {/* Movies */} - {movieResults.length > 0 && ( - - - Movies ({movieResults.length}) - - ( - - )} - keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - )} - - {/* TV Shows */} - {seriesResults.length > 0 && ( - - - TV Shows ({seriesResults.length}) - - ( - - )} - keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - )} - - {/* Other types */} - {otherResults.length > 0 && ( - - - {otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length}) - - ( - - )} - keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - )} - - ); - }, (prev, next) => { - // Only re-render if this section's reference changed - return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex; - }); - - // Set up listeners for watched status and library updates - // These will trigger re-renders in individual SearchResultItem components + // Set up listeners useEffect(() => { - const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', () => { - // Individual items will handle their own watched status updates - // No need to force a full re-render of all results - }); - const librarySub = catalogService.subscribeToLibraryUpdates(() => { - // Individual items will handle their own library status updates - // No need to force a full re-render of all results - }); - - return () => { - watchedSub.remove(); - librarySub(); - }; + const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', () => { }); + const librarySub = catalogService.subscribeToLibraryUpdates(() => { }); + return () => { watchedSub.remove(); librarySub(); }; }, []); return ( - - + + - {/* ScreenHeader Component */} - - - {/* Search Bar */} - - - - - - {query.length > 0 && ( - - - - )} - + + + + + + + {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 - + + {searching && results.byAddon.length === 0 ? ( + + ) : searched && !hasResultsToShow && !searching ? ( + + + {t('search.no_results')} + {t('search.try_keywords')} ) : ( { showsVerticalScrollIndicator={false} scrollEventThrottle={16} onScroll={({ nativeEvent }) => { - // Only paginate if query is empty (Discover mode) 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(); - } + if (isCloseToBottom) loadMoreDiscoverContent(); }} > {!query.trim() && renderRecentSearches()} - {!query.trim() && settings.showDiscover && renderDiscoverSection()} + {!query.trim() && settings.showDiscover && ( + + )} - {/* Render results grouped by addon using memoized component */} {results.byAddon.map((addonGroup, addonIndex) => ( ))} )} - {/* DropUpMenu integration for search results */} + + {/* DropUpMenu */} {selectedItem && ( { switch (option) { case 'share': { let url = ''; - if (selectedItem.id) { - url = `https://www.imdb.com/title/${selectedItem.id}/`; + if (selectedItem.type === 'movie') { + url = `https://www.imdb.com/title/${selectedItem.id}`; + } else { + url = `https://www.imdb.com/title/${selectedItem.id}`; } - const message = `${selectedItem.name}\n${url}`; - Share.share({ message, url, title: selectedItem.name }); + try { + await Share.share({ message: `Check out ${selectedItem.name}: ${url}`, url }); + } catch (e) { } break; } - case 'library': { + case 'save': if (isSaved) { await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); - setIsSaved(false); } else { await catalogService.addToLibrary(selectedItem); - setIsSaved(true); } + setIsSaved(!isSaved); break; - } - case 'watched': { - const key = `watched:${selectedItem.type}:${selectedItem.id}`; - const newWatched = !isWatched; - await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); - setIsWatched(newWatched); - break; - } - default: + case 'watched': + const newWatchedState = !isWatched; + await mmkvStorage.setItem(`watched:${selectedItem.type}:${selectedItem.id}`, newWatchedState ? 'true' : 'false'); + setIsWatched(newWatchedState); + DeviceEventEmitter.emit('watchedStatusChanged'); break; } + setMenuVisible(false); }} /> )} - {/* Catalog Selection Bottom Sheet */} - - - - Select Catalog - - catalogSheetRef.current?.dismiss()}> - - - - - {filteredCatalogs.map((catalog, index) => ( - handleCatalogSelect(catalog)} - > - - - {catalog.catalogName} - - - {catalog.addonName} • {catalog.type === 'movie' ? 'Movies' : 'TV Shows'} - {catalog.genres.length > 0 ? ` • ${catalog.genres.length} genres` : ''} - - - {selectedCatalog?.catalogId === catalog.catalogId && - selectedCatalog?.addonId === catalog.addonId && ( - - )} - - ))} - - - - {/* Genre Selection Bottom Sheet */} - - - - Select Genre - - genreSheetRef.current?.dismiss()}> - - - - - {/* All Genres option */} - handleGenreSelect(null)} - > - - - All Genres - - - Show all content - - - {!selectedDiscoverGenre && ( - - )} - - - {/* Genre options */} - {availableGenres.map((genre, index) => ( - handleGenreSelect(genre)} - > - - - {genre} - - - {selectedDiscoverGenre === genre && ( - - )} - - ))} - - - - {/* Type Selection Bottom Sheet */} - - - - Select Type - - typeSheetRef.current?.dismiss()}> - - - - - {/* Movies option */} - handleTypeSelect('movie')} - > - - - Movies - - - Browse movie catalogs - - - {selectedDiscoverType === 'movie' && ( - - )} - - - {/* TV Shows option */} - handleTypeSelect('series')} - > - - - TV Shows - - - Browse TV series catalogs - - - {selectedDiscoverType === 'series' && ( - - )} - - - + {/* Bottom Sheets */} + ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - contentContainer: { - flex: 1, - paddingTop: 0, - }, - 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, - }, - carouselSubtitle: { - fontSize: isTablet ? 16 : 14, - fontWeight: '600', - marginBottom: isTablet ? 12 : 8, - paddingHorizontal: 16, - }, - addonHeaderContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: isTablet ? 16 : 12, - marginTop: isTablet ? 24 : 16, - marginBottom: isTablet ? 8 : 4, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', - }, - addonHeaderIcon: { - // removed icon - }, - addonHeaderText: { - fontSize: isTablet ? 18 : 16, - fontWeight: '700', - flex: 1, - }, - addonHeaderBadge: { - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 12, - }, - addonHeaderBadgeText: { - fontSize: isTablet ? 12 : 11, - fontWeight: '600', - }, - horizontalListContent: { - paddingHorizontal: 16, - }, - horizontalItem: { - width: HORIZONTAL_ITEM_WIDTH, - marginRight: 16, - }, - horizontalItemPosterContainer: { - width: HORIZONTAL_ITEM_WIDTH, - height: HORIZONTAL_POSTER_HEIGHT, - borderRadius: 12, - overflow: 'hidden', - marginBottom: 8, - borderWidth: 1.5, - borderColor: 'rgba(255,255,255,0.15)', - // Consistent shadow/elevation matching ContentItem - elevation: Platform.OS === 'android' ? 1 : 0, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 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, - }, - 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, - left: 8, - borderRadius: 8, - padding: 4, - zIndex: 2, - backgroundColor: 'transparent', - }, - // Discover section styles - discoverContainer: { - paddingTop: isTablet ? 16 : 12, - paddingBottom: isTablet ? 24 : 16, - }, - discoverHeader: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - marginBottom: isTablet ? 16 : 12, - gap: 8, - }, - discoverTitle: { - fontSize: isTablet ? 22 : 20, - fontWeight: '700', - }, - discoverTypeContainer: { - flexDirection: 'row', - paddingHorizontal: 16, - marginBottom: isTablet ? 16 : 12, - gap: 12, - }, - discoverTypeButton: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 20, - backgroundColor: 'rgba(255,255,255,0.1)', - gap: 6, - }, - discoverTypeText: { - fontSize: isTablet ? 15 : 14, - fontWeight: '600', - }, - discoverGenreScroll: { - marginBottom: isTablet ? 20 : 16, - }, - discoverGenreContent: { - paddingHorizontal: 16, - gap: 8, - }, - discoverGenreChip: { - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 16, - backgroundColor: 'rgba(255,255,255,0.08)', - marginRight: 8, - }, - discoverGenreChipActive: { - backgroundColor: 'rgba(255,255,255,0.2)', - }, - discoverGenreText: { - fontSize: isTablet ? 14 : 13, - fontWeight: '500', - color: 'rgba(255,255,255,0.7)', - }, - discoverGenreTextActive: { - color: '#FFFFFF', - fontWeight: '600', - }, - discoverLoadingContainer: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 60, - }, - discoverLoadingText: { - marginTop: 12, - fontSize: 14, - }, - discoverAddonSection: { - marginBottom: isTablet ? 28 : 20, - }, - discoverAddonHeader: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - marginBottom: isTablet ? 12 : 8, - }, - discoverAddonName: { - fontSize: isTablet ? 16 : 15, - fontWeight: '600', - flex: 1, - }, - discoverAddonBadge: { - paddingHorizontal: 8, - paddingVertical: 3, - borderRadius: 10, - }, - discoverAddonBadgeText: { - fontSize: 11, - fontWeight: '600', - }, - discoverEmptyContainer: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 60, - paddingHorizontal: 32, - }, - discoverEmptyText: { - fontSize: 16, - fontWeight: '600', - marginTop: 12, - textAlign: 'center', - }, - discoverEmptySubtext: { - fontSize: 14, - marginTop: 4, - textAlign: 'center', - }, - discoverGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - paddingHorizontal: 16, - gap: 12, // vertical and horizontal gap - }, - discoverGridRow: { - justifyContent: 'flex-start', - gap: 12, - }, - discoverGridContent: { - paddingHorizontal: 16, - paddingBottom: 16, - }, - discoverGridItem: { - marginRight: 0, // Override horizontalItem margin - marginBottom: 12, - }, - loadingMoreContainer: { - width: '100%', - paddingVertical: 16, - alignItems: 'center', - justifyContent: 'center', - }, - // New chip-based discover styles - discoverChipsScroll: { - marginBottom: isTablet ? 12 : 10, - flexGrow: 0, - }, - discoverChipsContent: { - paddingHorizontal: 16, - flexDirection: 'row', - gap: 8, - }, - discoverSelectorChip: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - gap: 6, - }, - discoverSelectorText: { - fontSize: isTablet ? 14 : 13, - fontWeight: '600', - }, - discoverFilterSummary: { - paddingHorizontal: 16, - marginBottom: isTablet ? 16 : 12, - }, - discoverFilterSummaryText: { - fontSize: 12, - fontWeight: '500', - }, - // Bottom sheet styles - bottomSheetHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', - }, - bottomSheetTitle: { - fontSize: 18, - fontWeight: '700', - }, - bottomSheetContent: { - paddingHorizontal: 12, - paddingBottom: 40, - }, - bottomSheetItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 14, - paddingHorizontal: 12, - borderRadius: 12, - marginVertical: 2, - }, - bottomSheetItemSelected: { - backgroundColor: 'rgba(255,255,255,0.08)', - }, - bottomSheetItemIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: 'rgba(255,255,255,0.1)', - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - bottomSheetItemContent: { - flex: 1, - }, - bottomSheetItemTitle: { - fontSize: 16, - fontWeight: '600', - }, - bottomSheetItemSubtitle: { - fontSize: 13, - marginTop: 2, - }, - showMoreButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - paddingHorizontal: 24, - backgroundColor: 'rgba(255,255,255,0.1)', - borderRadius: 8, - marginVertical: 20, - alignSelf: 'center', - }, - showMoreButtonText: { - fontSize: 14, - fontWeight: '600', - marginRight: 8, - }, -}); - export default SearchScreen; From 168613849941b0f6171ff7b0cd9a269e10e681fc Mon Sep 17 00:00:00 2001 From: AdityasahuX07 Date: Wed, 7 Jan 2026 17:32:52 +0530 Subject: [PATCH 5/5] Update AppNavigator.tsx