discover screen optimization

This commit is contained in:
tapframe 2025-12-28 23:57:31 +05:30
parent cf5cc2d8f9
commit ff2bca18a5
8 changed files with 738 additions and 196 deletions

View file

@ -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<RecentSearchesProps> = ({
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 (
<View style={styles.container}>
<Text style={[styles.title, { color: currentTheme.colors.white }]}>
Recent Searches
</Text>
{recentSearches.map((search, index) => (
<TouchableOpacity
key={index}
style={styles.searchItem}
onPress={() => handlePress(search)}
>
<MaterialIcons
name="history"
size={20}
color={currentTheme.colors.lightGray}
style={styles.icon}
/>
<Text style={[styles.searchText, { color: currentTheme.colors.white }]}>
{search}
</Text>
<TouchableOpacity
onPress={() => handleDelete(index)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={styles.deleteButton}
>
<MaterialIcons name="close" size={16} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
);
};
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;

View file

@ -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 (
<RNAnimated.View
style={[
styles.container,
{ opacity: fadeAnim }
]}
>
<View style={styles.content}>
<RNAnimated.View style={[
styles.spinnerContainer,
{ transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary }
]}>
<MaterialIcons
name="search"
size={32}
color={currentTheme.colors.white}
/>
</RNAnimated.View>
<Text style={[styles.text, { color: currentTheme.colors.white }]}>Searching</Text>
</View>
</RNAnimated.View>
);
};
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;

View file

@ -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<SearchResultItemProps> = 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 (
<TouchableOpacity
style={[
styles.horizontalItem,
{ width: itemWidth },
isGrid && styles.discoverGridItem
]}
onPress={() => onPress(item)}
onLongPress={() => onLongPress(item)}
delayLongPress={300}
activeOpacity={0.7}
>
<View style={[styles.horizontalItemPosterContainer, {
width: itemWidth,
height: undefined,
aspectRatio: aspectRatio,
backgroundColor: currentTheme.colors.darkBackground,
borderRadius,
}]}>
<FastImage
source={{
uri: item.poster || PLACEHOLDER_POSTER,
priority: FastImage.priority.low,
cache: FastImage.cacheControl.immutable,
}}
style={[styles.horizontalItemPoster, { borderRadius }]}
resizeMode={FastImage.resizeMode.cover}
/>
{inLibrary && (
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }]}>
<Feather name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
{watched && (
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }]}>
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
</View>
<Text
style={[
styles.horizontalItemTitle,
{
color: currentTheme.colors.white,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14,
lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18,
}
]}
numberOfLines={2}
>
{item.name}
</Text>
{item.year && (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}>
{item.year}
</Text>
)}
</TouchableOpacity>
);
});
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;

View file

@ -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 = () => (
<View style={styles.skeletonVerticalItem}>
<RNAnimated.View style={[
styles.skeletonPoster,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<View style={styles.skeletonItemDetails}>
<RNAnimated.View style={[
styles.skeletonTitle,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<View style={styles.skeletonMetaRow}>
<RNAnimated.View style={[
styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<RNAnimated.View style={[
styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
</View>
</View>
</View>
);
return (
<View style={styles.skeletonContainer}>
{[...Array(5)].map((_, index) => (
<View key={index}>
{index === 0 && (
<RNAnimated.View style={[
styles.skeletonSectionHeader,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
)}
{renderSkeletonItem()}
</View>
))}
</View>
);
};
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;

View file

@ -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';

View file

@ -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;

View file

@ -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 = () => (
<View style={styles.skeletonVerticalItem}>
<RNAnimated.View style={[
styles.skeletonPoster,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<View style={styles.skeletonItemDetails}>
<RNAnimated.View style={[
styles.skeletonTitle,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<View style={styles.skeletonMetaRow}>
<RNAnimated.View style={[
styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<RNAnimated.View style={[
styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
</View>
</View>
</View>
);
return (
<View style={styles.skeletonContainer}>
{[...Array(5)].map((_, index) => (
<View key={index}>
{index === 0 && (
<RNAnimated.View style={[
styles.skeletonSectionHeader,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
)}
{renderSkeletonItem()}
</View>
))}
</View>
);
};
// 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 (
<RNAnimated.View
style={[
styles.simpleAnimationContainer,
{ opacity: fadeAnim }
]}
>
<View style={styles.simpleAnimationContent}>
<RNAnimated.View style={[
styles.spinnerContainer,
{ transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary }
]}>
<MaterialIcons
name="search"
size={32}
color={currentTheme.colors.white}
/>
</RNAnimated.View>
<Text style={[styles.simpleAnimationText, { color: currentTheme.colors.white }]}>Searching</Text>
</View>
</RNAnimated.View>
);
};
const SearchScreen = () => {
const { settings } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -264,6 +117,7 @@ const SearchScreen = () => {
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [discoverResults, setDiscoverResults] = useState<StreamingContent[]>([]);
const [pendingDiscoverResults, setPendingDiscoverResults] = useState<StreamingContent[]>([]);
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 = () => {
</Text>
</View>
) : discoverResults.length > 0 ? (
<View style={styles.discoverGrid}>
{discoverResults.map((item, index) => (
<FlatList
data={discoverResults}
keyExtractor={(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 }) => (
<SearchResultItem
key={`discover-${item.id}-${index}`}
item={item}
@ -905,13 +790,31 @@ const SearchScreen = () => {
currentTheme={currentTheme}
isGrid={true}
/>
))}
{loadingMore && (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
)}
</View>
initialNumToRender={9}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={true}
scrollEnabled={false}
ListFooterComponent={
pendingDiscoverResults.length > 0 ? (
<TouchableOpacity
style={styles.showMoreButton}
onPress={handleShowMore}
activeOpacity={0.7}
>
<Text style={[styles.showMoreButtonText, { color: currentTheme.colors.white }]}>
Show More ({pendingDiscoverResults.length})
</Text>
<MaterialIcons name="expand-more" size={20} color={currentTheme.colors.white} />
</TouchableOpacity>
) : loadingMore ? (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
) : null
}
/>
) : discoverInitialized && !discoverLoading && selectedCatalog ? (
<View style={styles.discoverEmptyContainer}>
<MaterialIcons name="movie-filter" size={48} color={currentTheme.colors.lightGray} />
@ -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,
}]}>
<FastImage
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
source={{
uri: item.poster || PLACEHOLDER_POSTER,
priority: FastImage.priority.low,
cache: FastImage.cacheControl.immutable,
}}
style={[styles.horizontalItemPoster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
resizeMode={FastImage.resizeMode.cover}
/>
@ -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;

View file

@ -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) {