diff --git a/src/components/search/AddonSection.tsx b/src/components/search/AddonSection.tsx
new file mode 100644
index 00000000..7d7c3b7a
--- /dev/null
+++ b/src/components/search/AddonSection.tsx
@@ -0,0 +1,155 @@
+import React, { useMemo } from 'react';
+import { View, Text, FlatList } from 'react-native';
+import { useTranslation } from 'react-i18next';
+import { AddonSearchResults, StreamingContent } from '../../services/catalogService';
+import { SearchResultItem } from './SearchResultItem';
+import { isTablet, isLargeTablet, isTV } from './searchUtils';
+import { searchStyles as styles } from './searchStyles';
+
+interface AddonSectionProps {
+ addonGroup: AddonSearchResults;
+ addonIndex: number;
+ onItemPress: (item: StreamingContent) => void;
+ onItemLongPress: (item: StreamingContent) => void;
+ currentTheme: any;
+}
+
+export const AddonSection = React.memo(({
+ addonGroup,
+ addonIndex,
+ onItemPress,
+ onItemLongPress,
+ currentTheme,
+}: AddonSectionProps) => {
+ const { t } = useTranslation();
+
+ 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 && (
+
+
+ {t('search.movies')} ({movieResults.length})
+
+ (
+
+ )}
+ keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={styles.horizontalListContent}
+ />
+
+ )}
+
+ {/* TV Shows */}
+ {seriesResults.length > 0 && (
+
+
+ {t('search.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;
+});
+
+AddonSection.displayName = 'AddonSection';
diff --git a/src/components/search/DiscoverBottomSheets.tsx b/src/components/search/DiscoverBottomSheets.tsx
new file mode 100644
index 00000000..88099769
--- /dev/null
+++ b/src/components/search/DiscoverBottomSheets.tsx
@@ -0,0 +1,266 @@
+import React, { useMemo, useCallback, forwardRef, RefObject } from 'react';
+import { View, Text, TouchableOpacity } from 'react-native';
+import { useTranslation } from 'react-i18next';
+import { MaterialIcons } from '@expo/vector-icons';
+import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
+import { DiscoverCatalog } from './searchUtils';
+import { searchStyles as styles } from './searchStyles';
+
+interface DiscoverBottomSheetsProps {
+ typeSheetRef: RefObject;
+ catalogSheetRef: RefObject;
+ genreSheetRef: RefObject;
+ selectedDiscoverType: 'movie' | 'series';
+ selectedCatalog: DiscoverCatalog | null;
+ selectedDiscoverGenre: string | null;
+ filteredCatalogs: DiscoverCatalog[];
+ availableGenres: string[];
+ onTypeSelect: (type: 'movie' | 'series') => void;
+ onCatalogSelect: (catalog: DiscoverCatalog) => void;
+ onGenreSelect: (genre: string | null) => void;
+ currentTheme: any;
+}
+
+export const DiscoverBottomSheets = ({
+ typeSheetRef,
+ catalogSheetRef,
+ genreSheetRef,
+ selectedDiscoverType,
+ selectedCatalog,
+ selectedDiscoverGenre,
+ filteredCatalogs,
+ availableGenres,
+ onTypeSelect,
+ onCatalogSelect,
+ onGenreSelect,
+ currentTheme,
+}: DiscoverBottomSheetsProps) => {
+ const { t } = useTranslation();
+
+ const typeSnapPoints = useMemo(() => ['25%'], []);
+ const catalogSnapPoints = useMemo(() => ['50%'], []);
+ const genreSnapPoints = useMemo(() => ['50%'], []);
+
+ const renderBackdrop = useCallback(
+ (props: any) => (
+
+ ),
+ []
+ );
+
+ return (
+ <>
+ {/* Catalog Selection Bottom Sheet */}
+
+
+
+ {t('search.select_catalog')}
+
+ catalogSheetRef.current?.dismiss()}>
+
+
+
+
+ {filteredCatalogs.map((catalog, index) => (
+ onCatalogSelect(catalog)}
+ >
+
+
+ {catalog.catalogName}
+
+
+ {catalog.addonName}
+
+
+ {selectedCatalog?.catalogId === catalog.catalogId &&
+ selectedCatalog?.addonId === catalog.addonId && (
+
+ )}
+
+ ))}
+
+
+
+ {/* Genre Selection Bottom Sheet */}
+
+
+
+ {t('search.select_genre')}
+
+ genreSheetRef.current?.dismiss()}>
+
+
+
+
+ {/* All Genres option */}
+ onGenreSelect(null)}
+ >
+
+
+ {t('search.all_genres')}
+
+
+ {t('search.show_all_content')}
+
+
+ {!selectedDiscoverGenre && (
+
+ )}
+
+
+ {/* Genre options */}
+ {availableGenres.map((genre, index) => (
+ onGenreSelect(genre)}
+ >
+
+
+ {genre}
+
+
+ {selectedDiscoverGenre === genre && (
+
+ )}
+
+ ))}
+
+
+
+ {/* Type Selection Bottom Sheet */}
+
+
+
+ {t('search.select_type')}
+
+ typeSheetRef.current?.dismiss()}>
+
+
+
+
+ {/* Movies option */}
+ onTypeSelect('movie')}
+ >
+
+
+ {t('search.movies')}
+
+
+ {t('search.browse_movies')}
+
+
+ {selectedDiscoverType === 'movie' && (
+
+ )}
+
+
+ {/* TV Shows option */}
+ onTypeSelect('series')}
+ >
+
+
+ {t('search.tv_shows')}
+
+
+ {t('search.browse_tv')}
+
+
+ {selectedDiscoverType === 'series' && (
+
+ )}
+
+
+
+ >
+ );
+};
+
+DiscoverBottomSheets.displayName = 'DiscoverBottomSheets';
diff --git a/src/components/search/DiscoverResultItem.tsx b/src/components/search/DiscoverResultItem.tsx
new file mode 100644
index 00000000..21982401
--- /dev/null
+++ b/src/components/search/DiscoverResultItem.tsx
@@ -0,0 +1,159 @@
+import React, { useMemo, useEffect, useState } from 'react';
+import { View, Text, TouchableOpacity, Dimensions, DeviceEventEmitter } from 'react-native';
+import { MaterialIcons, Feather } from '@expo/vector-icons';
+import FastImage from '@d11/react-native-fast-image';
+import { StreamingContent, catalogService } from '../../services/catalogService';
+import { mmkvStorage } from '../../services/mmkvStorage';
+import { useSettings } from '../../hooks/useSettings';
+import {
+ isTablet,
+ isLargeTablet,
+ isTV,
+ HORIZONTAL_ITEM_WIDTH,
+ HORIZONTAL_POSTER_HEIGHT,
+ PLACEHOLDER_POSTER,
+} from './searchUtils';
+import { searchStyles as styles } from './searchStyles';
+
+const { width } = Dimensions.get('window');
+
+interface DiscoverResultItemProps {
+ item: StreamingContent;
+ index: number;
+ navigation: any;
+ setSelectedItem: (item: StreamingContent) => void;
+ setMenuVisible: (visible: boolean) => void;
+ currentTheme: any;
+ isGrid?: boolean;
+}
+
+export const DiscoverResultItem = React.memo(({
+ item,
+ index,
+ navigation,
+ setSelectedItem,
+ setMenuVisible,
+ currentTheme,
+ isGrid = false
+}: DiscoverResultItemProps) => {
+ 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) {
+ // Grid Calculation: (Window Width - Padding) / Columns
+ 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]);
+
+ 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]);
+
+ return (
+ {
+ navigation.navigate('Metadata', {
+ id: item.id,
+ type: item.type,
+ addonId: item.addonId
+ });
+ }}
+ onLongPress={() => {
+ setSelectedItem(item);
+ setMenuVisible(true);
+ }}
+ delayLongPress={300}
+ activeOpacity={0.7}
+ >
+
+
+ {/* Bookmark icon */}
+ {inLibrary && (
+
+
+
+ )}
+ {/* Watched icon */}
+ {watched && (
+
+
+
+ )}
+
+
+ {item.name}
+
+ {item.year && (
+
+ {item.year}
+
+ )}
+
+ );
+});
+
+DiscoverResultItem.displayName = 'DiscoverResultItem';
diff --git a/src/components/search/DiscoverSection.tsx b/src/components/search/DiscoverSection.tsx
new file mode 100644
index 00000000..db83a562
--- /dev/null
+++ b/src/components/search/DiscoverSection.tsx
@@ -0,0 +1,198 @@
+import React from 'react';
+import {
+ View,
+ Text,
+ ScrollView,
+ TouchableOpacity,
+ ActivityIndicator,
+ FlatList,
+} from 'react-native';
+import { useTranslation } from 'react-i18next';
+import { MaterialIcons } from '@expo/vector-icons';
+import { StreamingContent } from '../../services/catalogService';
+import { DiscoverCatalog, isTablet, isLargeTablet, isTV } from './searchUtils';
+import { DiscoverResultItem } from './DiscoverResultItem';
+import { searchStyles as styles } from './searchStyles';
+import { BottomSheetModal } from '@gorhom/bottom-sheet';
+
+interface DiscoverSectionProps {
+ discoverLoading: boolean;
+ discoverInitialized: boolean;
+ discoverResults: StreamingContent[];
+ pendingDiscoverResults: StreamingContent[];
+ loadingMore: boolean;
+ selectedCatalog: DiscoverCatalog | null;
+ selectedDiscoverType: 'movie' | 'series';
+ selectedDiscoverGenre: string | null;
+ availableGenres: string[];
+ typeSheetRef: React.RefObject;
+ catalogSheetRef: React.RefObject;
+ genreSheetRef: React.RefObject;
+ handleShowMore: () => void;
+ navigation: any;
+ setSelectedItem: (item: StreamingContent) => void;
+ setMenuVisible: (visible: boolean) => void;
+ currentTheme: any;
+}
+
+export const DiscoverSection = ({
+ discoverLoading,
+ discoverInitialized,
+ discoverResults,
+ pendingDiscoverResults,
+ loadingMore,
+ selectedCatalog,
+ selectedDiscoverType,
+ selectedDiscoverGenre,
+ availableGenres,
+ typeSheetRef,
+ catalogSheetRef,
+ genreSheetRef,
+ handleShowMore,
+ navigation,
+ setSelectedItem,
+ setMenuVisible,
+ currentTheme,
+}: DiscoverSectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+ {/* Section Header */}
+
+
+ {t('search.discover')}
+
+
+
+ {/* Filter Chips Row */}
+
+ {/* Type Selector Chip (Movie/TV Show) */}
+ typeSheetRef.current?.present()}
+ >
+
+ {selectedDiscoverType === 'movie' ? t('search.movies') : t('search.tv_shows')}
+
+
+
+
+ {/* Catalog Selector Chip */}
+ catalogSheetRef.current?.present()}
+ >
+
+ {selectedCatalog ? selectedCatalog.catalogName : t('search.select_catalog')}
+
+
+
+
+ {/* Genre Selector Chip - only show if catalog has genres */}
+ {availableGenres.length > 0 && (
+ genreSheetRef.current?.present()}
+ >
+
+ {selectedDiscoverGenre || t('search.all_genres')}
+
+
+
+ )}
+
+
+ {/* Selected filters summary */}
+ {selectedCatalog && (
+
+
+ {selectedCatalog.addonName} • {selectedCatalog.type === 'movie' ? t('search.movies') : t('search.tv_shows')}
+ {selectedDiscoverGenre ? ` • ${selectedDiscoverGenre}` : ''}
+
+
+ )}
+
+ {/* Discover Results */}
+ {discoverLoading ? (
+
+
+
+ {t('search.discovering')}
+
+
+ ) : 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 ? (
+
+
+ {t('search.show_more', { count: pendingDiscoverResults.length })}
+
+
+
+ ) : loadingMore ? (
+
+
+
+ ) : null
+ }
+ />
+ ) : discoverInitialized && !discoverLoading && selectedCatalog ? (
+
+
+
+ {t('search.no_content_found')}
+
+
+ {t('search.try_different')}
+
+
+ ) : !selectedCatalog && discoverInitialized ? (
+
+
+
+ {t('search.select_catalog_desc')}
+
+
+ {t('search.tap_catalog_desc')}
+
+
+ ) : null}
+
+ );
+};
+
+DiscoverSection.displayName = 'DiscoverSection';
diff --git a/src/components/search/index.ts b/src/components/search/index.ts
index 40823a81..2515b275 100644
--- a/src/components/search/index.ts
+++ b/src/components/search/index.ts
@@ -1,6 +1,11 @@
// Search components barrel export
export * from './searchUtils';
+export { searchStyles } from './searchStyles';
export { SearchSkeletonLoader } from './SearchSkeletonLoader';
export { SearchAnimation } from './SearchAnimation';
export { SearchResultItem } from './SearchResultItem';
export { RecentSearches } from './RecentSearches';
+export { DiscoverResultItem } from './DiscoverResultItem';
+export { AddonSection } from './AddonSection';
+export { DiscoverSection } from './DiscoverSection';
+export { DiscoverBottomSheets } from './DiscoverBottomSheets';
diff --git a/src/components/search/searchStyles.ts b/src/components/search/searchStyles.ts
new file mode 100644
index 00000000..86664efe
--- /dev/null
+++ b/src/components/search/searchStyles.ts
@@ -0,0 +1,531 @@
+import { StyleSheet, Platform, Dimensions } from 'react-native';
+import { isTablet, isTV, isLargeTablet, HORIZONTAL_ITEM_WIDTH, HORIZONTAL_POSTER_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT } from './searchUtils';
+
+const { width } = Dimensions.get('window');
+
+export const searchStyles = 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,
+ 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)',
+ 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,
+ },
+ discoverGridRow: {
+ justifyContent: 'flex-start',
+ gap: 12,
+ },
+ discoverGridContent: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ },
+ discoverGridItem: {
+ marginRight: 0,
+ 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,
+ },
+});
diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx
index 054b9df8..014c8c38 100644
--- a/src/screens/SearchScreen.tsx
+++ b/src/screens/SearchScreen.tsx
@@ -2,94 +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 { useTranslation } from 'react-i18next';
-import { MaterialIcons, Feather } from '@expo/vector-icons';
+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);
@@ -99,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);
@@ -114,23 +76,28 @@ 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(() => {
@@ -141,19 +108,26 @@ const SearchScreen = () => {
useEffect(() => {
isMounted.current = true;
- return () => {
- isMounted.current = false;
- };
+ return () => { isMounted.current = false; };
}, []);
+ // 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);
};
@@ -164,21 +138,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]);
}
@@ -186,9 +154,7 @@ const SearchScreen = () => {
}
} catch (error) {
logger.error('Failed to load discover catalogs:', error);
- if (isMounted.current) {
- setDiscoverInitialized(true);
- }
+ if (isMounted.current) setDiscoverInitialized(true);
}
};
loadDiscoverCatalogs();
@@ -201,7 +167,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 {
@@ -210,28 +176,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);
}
};
@@ -256,12 +227,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);
@@ -269,38 +248,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 = () => {
@@ -310,60 +263,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);
}
@@ -378,11 +303,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();
}
@@ -392,9 +314,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);
}
@@ -407,10 +327,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) {
@@ -418,23 +335,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;
@@ -444,11 +357,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);
@@ -457,42 +367,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) {
@@ -501,7 +395,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++) {
@@ -517,25 +410,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);
@@ -543,8 +427,8 @@ const SearchScreen = () => {
}
}
};
+
useEffect(() => {
- // Skip initial mount to prevent unnecessary operations
if (isInitialMount.current) {
isInitialMount.current = false;
loadRecentSearches();
@@ -557,13 +441,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;
@@ -574,11 +456,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('');
@@ -595,9 +474,7 @@ const SearchScreen = () => {
if (!showRecent || recentSearches.length === 0) return null;
return (
-
+
{t('search.recent_searches')}
@@ -605,20 +482,10 @@ const SearchScreen = () => {
{
- setQuery(search);
- Keyboard.dismiss();
- }}
+ onPress={() => { setQuery(search); Keyboard.dismiss(); }}
>
-
-
- {search}
-
+
+ {search}
{
const newRecentSearches = [...recentSearches];
@@ -637,30 +504,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);
}
@@ -668,535 +541,64 @@ 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 */}
-
-
- {t('search.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' ? t('search.movies') : t('search.tv_shows')}
-
-
-
-
- {/* Catalog Selector Chip */}
- catalogSheetRef.current?.present()}
- >
-
- {selectedCatalog ? selectedCatalog.catalogName : t('search.select_catalog')}
-
-
-
-
- {/* Genre Selector Chip - only show if catalog has genres */}
- {availableGenres.length > 0 && (
- genreSheetRef.current?.present()}
- >
-
- {selectedDiscoverGenre || t('search.all_genres')}
-
-
-
- )}
-
-
- {/* Selected filters summary */}
- {selectedCatalog && (
-
-
- {selectedCatalog.addonName} • {selectedCatalog.type === 'movie' ? t('search.movies') : t('search.tv_shows')}
- {selectedDiscoverGenre ? ` • ${selectedDiscoverGenre}` : ''}
-
-
- )}
-
- {/* Discover Results */}
- {discoverLoading ? (
-
-
-
- {t('search.discovering')}
-
-
- ) : 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 ? (
-
-
- {t('search.show_more', { count: pendingDiscoverResults.length })}
-
-
-
- ) : loadingMore ? (
-
-
-
- ) : null
- }
- />
- ) : discoverInitialized && !discoverLoading && selectedCatalog ? (
-
-
-
- {t('search.no_content_found')}
-
-
- {t('search.try_different')}
-
-
- ) : !selectedCatalog && discoverInitialized ? (
-
-
-
- {t('search.select_catalog_desc')}
-
-
- {t('search.tap_catalog_desc')}
-
-
- ) : 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 && (
-
-
- {t('search.movies')} ({movieResults.length})
-
- (
-
- )}
- keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
- horizontal
- showsHorizontalScrollIndicator={false}
- contentContainerStyle={styles.horizontalListContent}
- />
-
- )}
-
- {/* TV Shows */}
- {seriesResults.length > 0 && (
-
-
- {t('search.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 && (
-
-
+
+
)}
@@ -1204,46 +606,14 @@ const SearchScreen = () => {
- {/* Content Container */}
-
- {searching ? (
-
-
-
- ) : query.trim().length === 1 ? (
-
-
-
- {t('search.keep_typing')}
-
-
- {t('search.type_characters')}
-
-
- ) : searched && !hasResultsToShow ? (
-
-
-
- {t('search.no_results')}
-
-
- {t('search.try_keywords')}
-
+
+ {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 */}
-
-
-
- {t('search.select_catalog')}
-
- catalogSheetRef.current?.dismiss()}>
-
-
-
-
- {filteredCatalogs.map((catalog, index) => (
- handleCatalogSelect(catalog)}
- >
-
-
- {catalog.catalogName}
-
-
- {catalog.addonName} • {catalog.type === 'movie' ? t('search.movies') : t('search.tv_shows')}
- {catalog.genres.length > 0 ? ` • ${t('search.genres_count', { count: catalog.genres.length })}` : ''}
-
-
- {selectedCatalog?.catalogId === catalog.catalogId &&
- selectedCatalog?.addonId === catalog.addonId && (
-
- )}
-
- ))}
-
-
-
- {/* Genre Selection Bottom Sheet */}
-
-
-
- {t('search.select_genre')}
-
- genreSheetRef.current?.dismiss()}>
-
-
-
-
- {/* All Genres option */}
- handleGenreSelect(null)}
- >
-
-
- {t('search.all_genres')}
-
-
- {t('search.show_all_content')}
-
-
- {!selectedDiscoverGenre && (
-
- )}
-
-
- {/* Genre options */}
- {availableGenres.map((genre, index) => (
- handleGenreSelect(genre)}
- >
-
-
- {genre}
-
-
- {selectedDiscoverGenre === genre && (
-
- )}
-
- ))}
-
-
-
- {/* Type Selection Bottom Sheet */}
-
-
-
- {t('search.select_type')}
-
- typeSheetRef.current?.dismiss()}>
-
-
-
-
- {/* Movies option */}
- handleTypeSelect('movie')}
- >
-
-
- {t('search.movies')}
-
-
- {t('search.browse_movies')}
-
-
- {selectedDiscoverType === 'movie' && (
-
- )}
-
-
- {/* TV Shows option */}
- handleTypeSelect('series')}
- >
-
-
- {t('search.tv_shows')}
-
-
- {t('search.browse_tv')}
-
-
- {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;
diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts
index 0c8e9060..2be93b92 100644
--- a/src/services/catalogService.ts
+++ b/src/services/catalogService.ts
@@ -132,6 +132,7 @@ export interface StreamingContent {
backdrop_path?: string;
};
addedToLibraryAt?: number; // Timestamp when added to library
+ addonId?: string; // ID of the addon that provided this content
}
export interface CatalogContent {
@@ -1203,29 +1204,29 @@ class CatalogService {
* @param catalogId - The catalog ID
* @param type - Content type (movie/series)
* @param genre - Optional genre filter
- * @param limit - Maximum items to return
+ * @param page - Page number for pagination (default 1)
*/
async discoverContentFromCatalog(
addonId: string,
catalogId: string,
type: string,
genre?: string,
- limit: number = 20
+ page: number = 1
): Promise {
try {
const manifests = await stremioService.getInstalledAddonsAsync();
const manifest = manifests.find(m => m.id === addonId);
-
+
if (!manifest) {
logger.error(`Addon ${addonId} not found`);
return [];
}
const filters = genre ? [{ title: 'genre', value: genre }] : [];
- const metas = await stremioService.getCatalog(manifest, type, catalogId, 1, filters);
+ const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
if (metas && metas.length > 0) {
- return metas.slice(0, limit).map(meta => this.convertMetaToStreamingContent(meta));
+ return metas.map(meta => this.convertMetaToStreamingContent(meta));
}
return [];
} catch (error) {