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