From ff2bca18a5bd7e06c62500983c54c7cc6cd0640b Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 28 Dec 2025 23:57:31 +0530 Subject: [PATCH] discover screen optimization --- src/components/search/RecentSearches.tsx | 112 +++++++ src/components/search/SearchAnimation.tsx | 111 +++++++ src/components/search/SearchResultItem.tsx | 197 +++++++++++ .../search/SearchSkeletonLoader.tsx | 126 +++++++ src/components/search/index.ts | 6 + src/components/search/searchUtils.ts | 46 +++ src/screens/SearchScreen.tsx | 314 +++++++----------- src/services/catalogService.ts | 22 +- 8 files changed, 738 insertions(+), 196 deletions(-) create mode 100644 src/components/search/RecentSearches.tsx create mode 100644 src/components/search/SearchAnimation.tsx create mode 100644 src/components/search/SearchResultItem.tsx create mode 100644 src/components/search/SearchSkeletonLoader.tsx create mode 100644 src/components/search/index.ts create mode 100644 src/components/search/searchUtils.ts diff --git a/src/components/search/RecentSearches.tsx b/src/components/search/RecentSearches.tsx new file mode 100644 index 00000000..bc14ec18 --- /dev/null +++ b/src/components/search/RecentSearches.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Keyboard, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '../../contexts/ThemeContext'; +import { mmkvStorage } from '../../services/mmkvStorage'; +import { RECENT_SEARCHES_KEY, isTablet } from './searchUtils'; + +interface RecentSearchesProps { + recentSearches: string[]; + onSearchPress: (query: string) => void; + onSearchesChange: (searches: string[]) => void; + visible: boolean; +} + +/** + * Recent search history list component + */ +export const RecentSearches: React.FC = ({ + recentSearches, + onSearchPress, + onSearchesChange, + visible, +}) => { + const { currentTheme } = useTheme(); + + if (!visible || recentSearches.length === 0) return null; + + const handleDelete = (index: number) => { + const newRecentSearches = [...recentSearches]; + newRecentSearches.splice(index, 1); + onSearchesChange(newRecentSearches); + mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); + }; + + const handlePress = (search: string) => { + onSearchPress(search); + Keyboard.dismiss(); + }; + + return ( + + + Recent Searches + + {recentSearches.map((search, index) => ( + handlePress(search)} + > + + + {search} + + handleDelete(index)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={styles.deleteButton} + > + + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingBottom: isTablet ? 24 : 16, + paddingTop: isTablet ? 12 : 8, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.05)', + marginBottom: isTablet ? 16 : 8, + }, + title: { + fontSize: isTablet ? 18 : 16, + fontWeight: '700', + marginBottom: 12, + }, + searchItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: isTablet ? 12 : 10, + paddingHorizontal: 16, + marginVertical: 1, + }, + icon: { + marginRight: 12, + }, + searchText: { + fontSize: 16, + flex: 1, + }, + deleteButton: { + padding: 4, + }, +}); + +export default RecentSearches; diff --git a/src/components/search/SearchAnimation.tsx b/src/components/search/SearchAnimation.tsx new file mode 100644 index 00000000..ee66cc84 --- /dev/null +++ b/src/components/search/SearchAnimation.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + Animated as RNAnimated, + Easing, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '../../contexts/ThemeContext'; + +/** + * Animated search indicator shown while searching + */ +export const SearchAnimation: React.FC = () => { + 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 styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 10, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.7)', + }, + content: { + alignItems: 'center', + justifyContent: 'center', + padding: 24, + borderRadius: 16, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + spinnerContainer: { + width: 64, + height: 64, + borderRadius: 32, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + text: { + fontSize: 16, + fontWeight: '600', + }, +}); + +export default SearchAnimation; diff --git a/src/components/search/SearchResultItem.tsx b/src/components/search/SearchResultItem.tsx new file mode 100644 index 00000000..b4315908 --- /dev/null +++ b/src/components/search/SearchResultItem.tsx @@ -0,0 +1,197 @@ +import React, { useMemo, useEffect, useState } from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + DeviceEventEmitter, + Dimensions, + Platform, +} from 'react-native'; +import FastImage from '@d11/react-native-fast-image'; +import { MaterialIcons, Feather } from '@expo/vector-icons'; +import { StreamingContent, catalogService } from '../../services/catalogService'; +import { mmkvStorage } from '../../services/mmkvStorage'; +import { useTheme } from '../../contexts/ThemeContext'; +import { useSettings } from '../../hooks/useSettings'; +import { + HORIZONTAL_ITEM_WIDTH, + HORIZONTAL_POSTER_HEIGHT, + PLACEHOLDER_POSTER, + isTablet, + isLargeTablet, + isTV, +} from './searchUtils'; + +const { width } = Dimensions.get('window'); + +interface SearchResultItemProps { + item: StreamingContent; + index: number; + onPress: (item: StreamingContent) => void; + onLongPress: (item: StreamingContent) => void; + isGrid?: boolean; +} + +/** + * Individual search result item with poster, title, and badges + */ +export const SearchResultItem: React.FC = React.memo(({ + item, + index, + onPress, + onLongPress, + isGrid = false, +}) => { + const { currentTheme } = useTheme(); + const { settings } = useSettings(); + const [inLibrary, setInLibrary] = useState(!!item.inLibrary); + const [watched, setWatched] = 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) { + // Ensure minimum 3 columns on all devices + const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3; + const minColumns = Math.max(3, columns); + const totalPadding = 32; + const totalGap = 12 * (minColumns - 1); + const availableWidth = width - totalPadding - totalGap; + w = availableWidth / minColumns; + } 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]); + + 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]); + + 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]); + + const borderRadius = settings.posterBorderRadius ?? 12; + + return ( + onPress(item)} + onLongPress={() => onLongPress(item)} + delayLongPress={300} + activeOpacity={0.7} + > + + + {inLibrary && ( + + + + )} + {watched && ( + + + + )} + + + {item.name} + + {item.year && ( + + {item.year} + + )} + + ); +}); + +const styles = StyleSheet.create({ + horizontalItem: { + marginRight: 16, + }, + discoverGridItem: { + marginRight: 0, + marginBottom: 0, + }, + horizontalItemPosterContainer: { + borderRadius: 12, + overflow: 'hidden', + marginBottom: 8, + borderWidth: 1.5, + borderColor: 'rgba(255,255,255,0.15)', + 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: 14, + fontWeight: '600', + lineHeight: 18, + textAlign: 'left', + }, + yearText: { + fontSize: 12, + marginTop: 2, + }, + libraryBadge: {}, + watchedIndicator: {}, +}); + +export default SearchResultItem; diff --git a/src/components/search/SearchSkeletonLoader.tsx b/src/components/search/SearchSkeletonLoader.tsx new file mode 100644 index 00000000..0acff5f9 --- /dev/null +++ b/src/components/search/SearchSkeletonLoader.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { + View, + StyleSheet, + Animated as RNAnimated, +} from 'react-native'; +import { useTheme } from '../../contexts/ThemeContext'; +import { isTablet } from './searchUtils'; + +/** + * Skeleton loader component for search results + */ +export const SearchSkeletonLoader: React.FC = () => { + 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 styles = StyleSheet.create({ + skeletonContainer: { + paddingHorizontal: 16, + paddingTop: 16, + }, + skeletonVerticalItem: { + flexDirection: 'row', + marginBottom: 16, + alignItems: 'center', + }, + skeletonPoster: { + width: isTablet ? 60 : 80, + height: isTablet ? 90 : 120, + borderRadius: 8, + marginRight: 12, + }, + skeletonItemDetails: { + flex: 1, + justifyContent: 'center', + }, + skeletonTitle: { + height: 16, + borderRadius: 4, + marginBottom: 8, + width: '80%', + }, + skeletonMetaRow: { + flexDirection: 'row', + gap: 8, + }, + skeletonMeta: { + height: 12, + borderRadius: 4, + width: 60, + }, + skeletonSectionHeader: { + height: 20, + width: 120, + borderRadius: 4, + marginBottom: 16, + }, +}); + +export default SearchSkeletonLoader; diff --git a/src/components/search/index.ts b/src/components/search/index.ts new file mode 100644 index 00000000..40823a81 --- /dev/null +++ b/src/components/search/index.ts @@ -0,0 +1,6 @@ +// Search components barrel export +export * from './searchUtils'; +export { SearchSkeletonLoader } from './SearchSkeletonLoader'; +export { SearchAnimation } from './SearchAnimation'; +export { SearchResultItem } from './SearchResultItem'; +export { RecentSearches } from './RecentSearches'; diff --git a/src/components/search/searchUtils.ts b/src/components/search/searchUtils.ts new file mode 100644 index 00000000..dd30880d --- /dev/null +++ b/src/components/search/searchUtils.ts @@ -0,0 +1,46 @@ +import { Dimensions } from 'react-native'; + +const { width } = Dimensions.get('window'); + +// Catalog info type for discover +export interface DiscoverCatalog { + addonId: string; + addonName: string; + catalogId: string; + catalogName: string; + type: string; + genres: string[]; +} + +// Enhanced responsive breakpoints +export const BREAKPOINTS = { + phone: 0, + tablet: 768, + largeTablet: 1024, + tv: 1440, +} as const; + +export const getDeviceType = (deviceWidth: number) => { + if (deviceWidth >= BREAKPOINTS.tv) return 'tv'; + if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet'; + if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet'; + return 'phone'; +}; + +// Current device calculations +export const deviceType = getDeviceType(width); +export const isTablet = deviceType === 'tablet'; +export const isLargeTablet = deviceType === 'largeTablet'; +export const isTV = deviceType === 'tv'; + +// Constants +export const TAB_BAR_HEIGHT = 85; +export const RECENT_SEARCHES_KEY = 'recent_searches'; +export const MAX_RECENT_SEARCHES = 10; +export const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; + +// Responsive poster sizes +export const HORIZONTAL_ITEM_WIDTH = isTV ? width * 0.14 : isLargeTablet ? width * 0.16 : isTablet ? width * 0.18 : width * 0.3; +export const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; +export const POSTER_WIDTH = isTV ? 90 : isLargeTablet ? 80 : isTablet ? 70 : 90; +export const POSTER_HEIGHT = POSTER_WIDTH * 1.5; diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 9bdfff43..f86417a0 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -49,188 +49,41 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext'; import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'; import { useSettings } from '../hooks/useSettings'; -// Catalog info type for discover -interface DiscoverCatalog { - addonId: string; - addonName: string; - catalogId: string; - catalogName: string; - type: string; - genres: string[]; -} +// 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 { SearchAnimation } from '../components/search/SearchAnimation'; +import { SearchResultItem } from '../components/search/SearchResultItem'; +import { RecentSearches } from '../components/search/RecentSearches'; const { width, height } = Dimensions.get('window'); -// Enhanced responsive breakpoints -const BREAKPOINTS = { - phone: 0, - tablet: 768, - largeTablet: 1024, - tv: 1440, -}; - -const getDeviceType = (deviceWidth: number) => { - if (deviceWidth >= BREAKPOINTS.tv) return 'tv'; - if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet'; - if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet'; - return 'phone'; -}; - +// Re-export for local use (backward compatibility) const deviceType = getDeviceType(width); -const isTablet = deviceType === 'tablet'; -const isLargeTablet = deviceType === 'largeTablet'; -const isTV = deviceType === 'tv'; -const TAB_BAR_HEIGHT = 85; - -// Responsive poster sizes -const HORIZONTAL_ITEM_WIDTH = isTV ? width * 0.14 : isLargeTablet ? width * 0.16 : isTablet ? width * 0.18 : width * 0.3; -const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; -const POSTER_WIDTH = isTV ? 90 : isLargeTablet ? 80 : isTablet ? 70 : 90; -const POSTER_HEIGHT = POSTER_WIDTH * 1.5; -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()} - - ))} - - ); -}; +// Alias imported components for backward compatibility with existing code +const SkeletonLoader = SearchSkeletonLoader; +const SimpleSearchAnimation = SearchAnimation; 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 { settings } = useSettings(); const navigation = useNavigation>(); @@ -264,6 +117,7 @@ const SearchScreen = () => { 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); @@ -290,6 +144,18 @@ const SearchScreen = () => { }; }, []); + const handleShowMore = () => { + if (pendingDiscoverResults.length === 0) return; + + // Show next batch of 300 items + const batchSize = 300; + 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 () => { @@ -335,6 +201,7 @@ const SearchScreen = () => { setDiscoverLoading(true); setPage(1); // Reset page on new filter setHasMore(true); + setPendingDiscoverResults([]); try { const results = await catalogService.discoverContentFromCatalog( selectedCatalog.addonId, @@ -344,8 +211,15 @@ const SearchScreen = () => { 1 // page 1 ); if (isMounted.current) { - setDiscoverResults(results); - setHasMore(results.length > 0); + if (results.length > 300) { + setDiscoverResults(results.slice(0, 300)); + setPendingDiscoverResults(results.slice(300)); + setHasMore(true); + } else { + setDiscoverResults(results); + setPendingDiscoverResults([]); + setHasMore(results.length > 0); + } } } catch (error) { logger.error('Failed to fetch discover content:', error); @@ -364,7 +238,7 @@ const SearchScreen = () => { // Load more content for pagination const loadMoreDiscoverContent = async () => { - if (!hasMore || loadingMore || discoverLoading || !selectedCatalog) return; + if (!hasMore || loadingMore || discoverLoading || !selectedCatalog || pendingDiscoverResults.length > 0) return; setLoadingMore(true); const nextPage = page + 1; @@ -380,7 +254,12 @@ const SearchScreen = () => { if (isMounted.current) { if (moreResults.length > 0) { - setDiscoverResults(prev => [...prev, ...moreResults]); + if (moreResults.length > 300) { + setDiscoverResults(prev => [...prev, ...moreResults.slice(0, 300)]); + setPendingDiscoverResults(moreResults.slice(300)); + } else { + setDiscoverResults(prev => [...prev, ...moreResults]); + } setPage(nextPage); } else { setHasMore(false); @@ -893,8 +772,14 @@ const SearchScreen = () => { ) : discoverResults.length > 0 ? ( - - {discoverResults.map((item, index) => ( + `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 }) => ( { currentTheme={currentTheme} isGrid={true} /> - ))} - {loadingMore && ( - - - )} - + initialNumToRender={9} + maxToRenderPerBatch={6} + windowSize={5} + removeClippedSubviews={true} + scrollEnabled={false} + ListFooterComponent={ + pendingDiscoverResults.length > 0 ? ( + + + Show More ({pendingDiscoverResults.length}) + + + + ) : loadingMore ? ( + + + + ) : null + } + /> ) : discoverInitialized && !discoverLoading && selectedCatalog ? ( @@ -961,11 +864,12 @@ const SearchScreen = () => { // 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 * (columns - 1); + const totalGap = 12 * (Math.max(3, columns) - 1); const availableWidth = width - totalPadding - totalGap; - w = availableWidth / columns; + w = availableWidth / Math.max(3, columns); } else { if (shape === 'landscape') { r = 16 / 9; @@ -1020,7 +924,11 @@ const SearchScreen = () => { borderRadius: settings.posterBorderRadius ?? 12, }]}> @@ -1342,7 +1250,7 @@ const SearchScreen = () => { scrollEventThrottle={16} onScroll={({ nativeEvent }) => { // Only paginate if query is empty (Discover mode) - if (query.trim().length > 0 || !settings.showDiscover) return; + 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; @@ -1418,6 +1326,8 @@ const SearchScreen = () => { enableDynamicSizing={false} enablePanDownToClose={true} backdropComponent={renderBackdrop} + android_keyboardInputMode="adjustResize" + animateOnMount={true} backgroundStyle={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C', borderTopLeftRadius: 16, @@ -1476,6 +1386,8 @@ const SearchScreen = () => { enableDynamicSizing={false} enablePanDownToClose={true} backdropComponent={renderBackdrop} + android_keyboardInputMode="adjustResize" + animateOnMount={true} backgroundStyle={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C', borderTopLeftRadius: 16, @@ -2030,9 +1942,17 @@ const styles = StyleSheet.create({ 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: 0, // Gap handles this now + marginBottom: 12, }, loadingMoreContainer: { width: '100%', @@ -2119,6 +2039,22 @@ const styles = StyleSheet.create({ 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; diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 79dfb27c..9c332669 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -1142,18 +1142,16 @@ class CatalogService { const supportsGenre = catalog.extra?.some(e => e.name === 'genre') || catalog.extraSupported?.includes('genre'); - // If genre is specified, only use catalogs that support genre OR have no filter restrictions - // If genre is specified but catalog doesn't support genre filter, skip it - if (genre && !supportsGenre) { - continue; - } + // If genre is specified but not supported, we still fetch but without the filter + // This ensures we don't skip addons that don't support the filter const manifest = manifests.find(m => m.id === addon.id); if (!manifest) continue; const fetchPromise = (async () => { try { - const filters = genre ? [{ title: 'genre', value: genre }] : []; + // Only apply genre filter if supported + const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : []; const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); if (metas && metas.length > 0) { @@ -1220,7 +1218,17 @@ class CatalogService { return []; } - const filters = genre ? [{ title: 'genre', value: genre }] : []; + // Find the catalog to check if it supports genre filter + const addon = (await this.getAllAddons()).find(a => a.id === addonId); + const catalog = addon?.catalogs?.find(c => c.id === catalogId); + + // Check if catalog supports genre filter + const supportsGenre = catalog?.extra?.some((e: any) => e.name === 'genre') || + catalog?.extraSupported?.includes('genre'); + + // Only apply genre filter if the catalog supports it + const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : []; + const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters); if (metas && metas.length > 0) {