discover screen init

This commit is contained in:
tapframe 2025-12-28 22:10:40 +05:30
parent 18e90397d9
commit a30fa604d7
5 changed files with 985 additions and 33 deletions

29
App.tsx
View file

@ -22,6 +22,7 @@ import AppNavigator, {
CustomNavigationDarkTheme,
CustomDarkTheme
} from './src/navigation/AppNavigator';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import 'react-native-reanimated';
import { CatalogProvider } from './src/contexts/CatalogContext';
import { GenreProvider } from './src/contexts/GenreContext';
@ -245,19 +246,21 @@ const ThemedApp = () => {
function App(): React.JSX.Element {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<GenreProvider>
<CatalogProvider>
<TraktProvider>
<ThemeProvider>
<TrailerProvider>
<ToastProvider>
<ThemedApp />
</ToastProvider>
</TrailerProvider>
</ThemeProvider>
</TraktProvider>
</CatalogProvider>
</GenreProvider>
<BottomSheetModalProvider>
<GenreProvider>
<CatalogProvider>
<TraktProvider>
<ThemeProvider>
<TrailerProvider>
<ToastProvider>
<ThemedApp />
</ToastProvider>
</TrailerProvider>
</ThemeProvider>
</TraktProvider>
</CatalogProvider>
</GenreProvider>
</BottomSheetModalProvider>
</GestureHandlerRootView>
);
}

View file

@ -92,6 +92,7 @@ export interface AppSettings {
videoPlayerEngine: 'auto' | 'mpv'; // Video player engine: auto (ExoPlayer primary, MPV fallback) or mpv (MPV only)
decoderMode: 'auto' | 'sw' | 'hw' | 'hw+'; // Decoder mode: auto (auto-copy), sw (software), hw (mediacodec-copy), hw+ (mediacodec)
gpuMode: 'gpu' | 'gpu-next'; // GPU rendering mode: gpu (standard) or gpu-next (advanced HDR/color)
showDiscover: boolean;
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -157,6 +158,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
videoPlayerEngine: 'auto', // Default to auto (ExoPlayer primary, MPV fallback)
decoderMode: 'auto', // Default to auto (best compatibility and performance)
gpuMode: 'gpu', // Default to gpu (gpu-next for advanced HDR)
showDiscover: true, // Show Discover section in SearchScreen
};
const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -17,6 +17,7 @@ import {
Pressable,
Platform,
Easing,
Modal,
} from 'react-native';
import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
@ -45,6 +46,18 @@ 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 { useSettings } from '../hooks/useSettings';
// Catalog info type for discover
interface DiscoverCatalog {
addonId: string;
addonName: string;
catalogId: string;
catalogName: string;
type: string;
genres: string[];
}
const { width, height } = Dimensions.get('window');
@ -219,6 +232,7 @@ const SimpleSearchAnimation = () => {
};
const SearchScreen = () => {
const { settings } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = true;
const [query, setQuery] = useState('');
@ -240,6 +254,28 @@ const SearchScreen = () => {
const isMounted = useRef(true);
const scrollViewRef = useRef<ScrollView>(null);
// Discover section state
const [discoverCatalogs, setDiscoverCatalogs] = useState<DiscoverCatalog[]>([]);
const [selectedCatalog, setSelectedCatalog] = useState<DiscoverCatalog | null>(null);
const [selectedDiscoverType, setSelectedDiscoverType] = useState<'movie' | 'series'>('movie');
const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState<string | null>(null);
// Discover pagination state
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [discoverResults, setDiscoverResults] = useState<StreamingContent[]>([]);
const [discoverLoading, setDiscoverLoading] = useState(false);
const [discoverInitialized, setDiscoverInitialized] = useState(false);
// Bottom sheet refs and state
const typeSheetRef = useRef<BottomSheetModal>(null);
const catalogSheetRef = useRef<BottomSheetModal>(null);
const genreSheetRef = useRef<BottomSheetModal>(null);
const typeSnapPoints = useMemo(() => ['25%'], []);
const catalogSnapPoints = useMemo(() => ['50%'], []);
const genreSnapPoints = useMemo(() => ['50%'], []);
// Scroll to top handler
const scrollToTop = useCallback(() => {
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
@ -253,6 +289,112 @@ const SearchScreen = () => {
isMounted.current = false;
};
}, []);
// Load discover catalogs on mount
useEffect(() => {
const loadDiscoverCatalogs = async () => {
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,
});
}
}
}
setDiscoverCatalogs(allCatalogs);
// Auto-select first catalog if available
if (allCatalogs.length > 0) {
setSelectedCatalog(allCatalogs[0]);
}
setDiscoverInitialized(true);
}
} catch (error) {
logger.error('Failed to load discover catalogs:', error);
if (isMounted.current) {
setDiscoverInitialized(true);
}
}
};
loadDiscoverCatalogs();
}, []);
// Fetch discover content when catalog or genre changes
useEffect(() => {
if (!discoverInitialized || !selectedCatalog || query.trim().length > 0) return;
const fetchDiscoverContent = async () => {
if (!isMounted.current) return;
setDiscoverLoading(true);
setPage(1); // Reset page on new filter
setHasMore(true);
try {
const results = await catalogService.discoverContentFromCatalog(
selectedCatalog.addonId,
selectedCatalog.catalogId,
selectedCatalog.type,
selectedDiscoverGenre || undefined,
1 // page 1
);
if (isMounted.current) {
setDiscoverResults(results);
setHasMore(results.length > 0);
}
} catch (error) {
logger.error('Failed to fetch discover content:', error);
if (isMounted.current) {
setDiscoverResults([]);
}
} finally {
if (isMounted.current) {
setDiscoverLoading(false);
}
}
};
fetchDiscoverContent();
}, [discoverInitialized, selectedCatalog, selectedDiscoverGenre, query]);
// Load more content for pagination
const loadMoreDiscoverContent = async () => {
if (!hasMore || loadingMore || discoverLoading || !selectedCatalog) return;
setLoadingMore(true);
const nextPage = page + 1;
try {
const moreResults = await catalogService.discoverContentFromCatalog(
selectedCatalog.addonId,
selectedCatalog.catalogId,
selectedCatalog.type,
selectedDiscoverGenre || undefined,
nextPage
);
if (isMounted.current) {
if (moreResults.length > 0) {
setDiscoverResults(prev => [...prev, ...moreResults]);
setPage(nextPage);
} else {
setHasMore(false);
}
}
} catch (error) {
logger.error('Failed to load more discover content:', error);
} finally {
if (isMounted.current) {
setLoadingMore(false);
}
}
};
// DropUpMenu state
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
@ -614,13 +756,195 @@ const SearchScreen = () => {
);
};
const SearchResultItem = ({ item, index, navigation, setSelectedItem, setMenuVisible, currentTheme }: {
// Get available genres for the selected catalog
const availableGenres = useMemo(() => {
if (!selectedCatalog) return [];
return selectedCatalog.genres;
}, [selectedCatalog]);
// 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]);
} else {
setSelectedDiscoverGenre(null);
}
} else {
setSelectedCatalog(null);
setSelectedDiscoverGenre(null);
}
typeSheetRef.current?.dismiss();
};
// Handle catalog selection
const handleCatalogSelect = (catalog: DiscoverCatalog) => {
setSelectedCatalog(catalog);
setSelectedDiscoverGenre(null); // Reset genre when catalog changes
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) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
// Render discover section with catalog and genre selector chips
const renderDiscoverSection = () => {
if (query.trim().length > 0) return null;
return (
<View style={styles.discoverContainer}>
{/* Section Header */}
<View style={styles.discoverHeader}>
<Text style={[styles.discoverTitle, { color: currentTheme.colors.white }]}>
Discover
</Text>
</View>
{/* Filter Chips Row */}
{/* Filter Chips Row */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.discoverChipsScroll}
contentContainerStyle={styles.discoverChipsContent}
>
{/* Type Selector Chip (Movie/TV Show) */}
<TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => typeSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedDiscoverType === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
{/* Catalog Selector Chip */}
<TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => catalogSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedCatalog ? selectedCatalog.catalogName : 'Select Catalog'}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
{/* Genre Selector Chip - only show if catalog has genres */}
{availableGenres.length > 0 && (
<TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => genreSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedDiscoverGenre || 'All Genres'}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
)}
</ScrollView>
{/* Selected filters summary */}
{selectedCatalog && (
<View style={styles.discoverFilterSummary}>
<Text style={[styles.discoverFilterSummaryText, { color: currentTheme.colors.lightGray }]}>
{selectedCatalog.addonName} {selectedCatalog.type === 'movie' ? 'Movies' : 'TV Shows'}
{selectedDiscoverGenre ? `${selectedDiscoverGenre}` : ''}
</Text>
</View>
)}
{/* Discover Results */}
{discoverLoading ? (
<View style={styles.discoverLoadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.discoverLoadingText, { color: currentTheme.colors.lightGray }]}>
Discovering content...
</Text>
</View>
) : discoverResults.length > 0 ? (
<View style={styles.discoverGrid}>
{discoverResults.map((item, index) => (
<SearchResultItem
key={`discover-${item.id}-${index}`}
item={item}
index={index}
navigation={navigation}
setSelectedItem={setSelectedItem}
setMenuVisible={setMenuVisible}
currentTheme={currentTheme}
isGrid={true}
/>
))}
{loadingMore && (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
)}
</View>
) : discoverInitialized && !discoverLoading && selectedCatalog ? (
<View style={styles.discoverEmptyContainer}>
<MaterialIcons name="movie-filter" size={48} color={currentTheme.colors.lightGray} />
<Text style={[styles.discoverEmptyText, { color: currentTheme.colors.lightGray }]}>
No content found
</Text>
<Text style={[styles.discoverEmptySubtext, { color: currentTheme.colors.mediumGray }]}>
Try a different genre or catalog
</Text>
</View>
) : !selectedCatalog && discoverInitialized ? (
<View style={styles.discoverEmptyContainer}>
<MaterialIcons name="touch-app" size={48} color={currentTheme.colors.lightGray} />
<Text style={[styles.discoverEmptyText, { color: currentTheme.colors.lightGray }]}>
Select a catalog to discover
</Text>
<Text style={[styles.discoverEmptySubtext, { color: currentTheme.colors.mediumGray }]}>
Tap the catalog chip above to get started
</Text>
</View>
) : null}
</View>
);
};
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);
@ -633,15 +957,26 @@ const SearchScreen = () => {
let w = HORIZONTAL_ITEM_WIDTH;
let r = 2 / 3;
if (shape === 'landscape') {
r = 16 / 9;
w = baseHeight * r;
} else if (shape === 'square') {
r = 1;
w = baseHeight;
if (isGrid) {
// Grid Calculation: (Window Width - Padding) / Columns
// Padding: 16 (left) + 16 (right) = 32
// Gap: 12 (between items) * (columns - 1)
const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3;
const totalPadding = 32;
const totalGap = 12 * (columns - 1);
const availableWidth = width - totalPadding - totalGap;
w = availableWidth / 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]);
}, [item.posterShape, isGrid]);
React.useEffect(() => {
const updateWatched = () => {
@ -661,7 +996,11 @@ const SearchScreen = () => {
return (
<TouchableOpacity
style={[styles.horizontalItem, { width: itemWidth }]}
style={[
styles.horizontalItem,
{ width: itemWidth },
isGrid && styles.discoverGridItem
]}
onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type });
}}
@ -698,14 +1037,7 @@ const SearchScreen = () => {
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
{item.imdbRating && (
<View style={styles.ratingContainer}>
<MaterialIcons name="star" size={12} color="#FFC107" />
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
{item.imdbRating}
</Text>
</View>
)}
{/* Rating removed per user request */}
</View>
<Text
style={[
@ -1009,8 +1341,22 @@ const SearchScreen = () => {
keyboardShouldPersistTaps="handled"
onScrollBeginDrag={Keyboard.dismiss}
showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
onScroll={({ nativeEvent }) => {
// Only paginate if query is empty (Discover mode)
if (query.trim().length > 0 || !settings.showDiscover) return;
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 500;
if (isCloseToBottom) {
loadMoreDiscoverContent();
}
}}
>
{!query.trim() && renderRecentSearches()}
{!query.trim() && settings.showDiscover && renderDiscoverSection()}
{/* Render results grouped by addon using memoized component */}
{results.byAddon.map((addonGroup, addonIndex) => (
<AddonSection
@ -1065,6 +1411,210 @@ const SearchScreen = () => {
}}
/>
)}
{/* Catalog Selection Bottom Sheet */}
<BottomSheetModal
ref={catalogSheetRef}
index={0}
snapPoints={catalogSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
}}
>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
Select Catalog
</Text>
<TouchableOpacity onPress={() => catalogSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<BottomSheetScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{filteredCatalogs.map((catalog, index) => (
<TouchableOpacity
key={`${catalog.addonId}-${catalog.catalogId}-${index}`}
style={[
styles.bottomSheetItem,
selectedCatalog?.catalogId === catalog.catalogId &&
selectedCatalog?.addonId === catalog.addonId &&
styles.bottomSheetItemSelected
]}
onPress={() => handleCatalogSelect(catalog)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{catalog.catalogName}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{catalog.addonName} {catalog.type === 'movie' ? 'Movies' : 'TV Shows'}
{catalog.genres.length > 0 ? `${catalog.genres.length} genres` : ''}
</Text>
</View>
{selectedCatalog?.catalogId === catalog.catalogId &&
selectedCatalog?.addonId === catalog.addonId && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
))}
</BottomSheetScrollView>
</BottomSheetModal>
{/* Genre Selection Bottom Sheet */}
<BottomSheetModal
ref={genreSheetRef}
index={0}
snapPoints={genreSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
}}
>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
Select Genre
</Text>
<TouchableOpacity onPress={() => genreSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<BottomSheetScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{/* All Genres option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
!selectedDiscoverGenre && styles.bottomSheetItemSelected
]}
onPress={() => handleGenreSelect(null)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
All Genres
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
Show all content
</Text>
</View>
{!selectedDiscoverGenre && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
{/* Genre options */}
{availableGenres.map((genre, index) => (
<TouchableOpacity
key={`${genre}-${index}`}
style={[
styles.bottomSheetItem,
selectedDiscoverGenre === genre && styles.bottomSheetItemSelected
]}
onPress={() => handleGenreSelect(genre)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{genre}
</Text>
</View>
{selectedDiscoverGenre === genre && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
))}
</BottomSheetScrollView>
</BottomSheetModal>
{/* Type Selection Bottom Sheet */}
<BottomSheetModal
ref={typeSheetRef}
index={0}
snapPoints={typeSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
}}
>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
Select Type
</Text>
<TouchableOpacity onPress={() => typeSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<BottomSheetScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{/* Movies option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'movie' && styles.bottomSheetItemSelected
]}
onPress={() => handleTypeSelect('movie')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
Movies
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
Browse movie catalogs
</Text>
</View>
{selectedDiscoverType === 'movie' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
{/* TV Shows option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'series' && styles.bottomSheetItemSelected
]}
onPress={() => handleTypeSelect('series')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
TV Shows
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
Browse TV series catalogs
</Text>
</View>
{selectedDiscoverType === 'series' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</BottomSheetScrollView>
</BottomSheetModal>
</View>
);
};
@ -1164,12 +1714,11 @@ const styles = StyleSheet.create({
fontWeight: '600',
},
horizontalListContent: {
paddingHorizontal: isTablet ? 16 : 12,
paddingRight: isTablet ? 12 : 8,
paddingHorizontal: 16,
},
horizontalItem: {
width: HORIZONTAL_ITEM_WIDTH,
marginRight: isTablet ? 16 : 12,
marginRight: 16,
},
horizontalItemPosterContainer: {
width: HORIZONTAL_ITEM_WIDTH,
@ -1360,6 +1909,211 @@ const styles = StyleSheet.create({
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
},
discoverGridItem: {
marginRight: 0, // Override horizontalItem margin
marginBottom: 0, // Gap handles this now
},
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,
},
});
export default SearchScreen;

View file

@ -594,6 +594,18 @@ const SettingsScreen: React.FC = () => {
onPress={() => navigation.navigate('HomeScreenSettings')}
isTablet={isTablet}
/>
<SettingItem
title="Show Discover Section"
description="Display discover content in Search"
icon="compass"
renderControl={() => (
<CustomSwitch
value={settings?.showDiscover ?? true}
onValueChange={(value) => updateSetting('showDiscover', value)}
/>
)}
isTablet={isTablet}
/>
<SettingItem
title="Continue Watching"
description="Cache and playback behavior"

View file

@ -1052,6 +1052,187 @@ class CatalogService {
return this.recentContent;
}
/**
* Get all available discover filters (genres, etc.) from installed addon catalogs
* This aggregates genre options from all addons that have catalog extras with options
*/
async getDiscoverFilters(): Promise<{
genres: string[];
types: string[];
catalogsByType: Record<string, { addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }[]>;
}> {
const addons = await this.getAllAddons();
const allGenres = new Set<string>();
const allTypes = new Set<string>();
const catalogsByType: Record<string, { addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }[]> = {};
for (const addon of addons) {
if (!addon.catalogs) continue;
for (const catalog of addon.catalogs) {
// Track content types
if (catalog.type) {
allTypes.add(catalog.type);
}
// Get genres from catalog extras
const catalogGenres: string[] = [];
if (catalog.extra && Array.isArray(catalog.extra)) {
for (const extra of catalog.extra) {
if (extra.name === 'genre' && extra.options && Array.isArray(extra.options)) {
for (const genre of extra.options) {
allGenres.add(genre);
catalogGenres.push(genre);
}
}
}
}
// Track catalogs by type for filtering
if (catalog.type) {
if (!catalogsByType[catalog.type]) {
catalogsByType[catalog.type] = [];
}
catalogsByType[catalog.type].push({
addonId: addon.id,
addonName: addon.name,
catalogId: catalog.id,
catalogName: catalog.name || catalog.id,
genres: catalogGenres
});
}
}
}
// Sort genres alphabetically
const sortedGenres = Array.from(allGenres).sort((a, b) => a.localeCompare(b));
const sortedTypes = Array.from(allTypes);
return {
genres: sortedGenres,
types: sortedTypes,
catalogsByType
};
}
/**
* Discover content by type and optional genre filter
* Fetches from all installed addons that have catalogs matching the criteria
*/
async discoverContent(
type: string,
genre?: string,
limit: number = 20
): Promise<{ addonName: string; items: StreamingContent[] }[]> {
const addons = await this.getAllAddons();
const results: { addonName: string; items: StreamingContent[] }[] = [];
const manifests = await stremioService.getInstalledAddonsAsync();
// Find catalogs that match the type
const catalogPromises: Promise<{ addonName: string; items: StreamingContent[] } | null>[] = [];
for (const addon of addons) {
if (!addon.catalogs) continue;
// Find catalogs matching the type
const matchingCatalogs = addon.catalogs.filter(catalog => catalog.type === type);
for (const catalog of matchingCatalogs) {
// Check if this catalog supports the genre filter
const supportsGenre = catalog.extra?.some(e => e.name === 'genre') ||
catalog.extraSupported?.includes('genre');
// If genre is specified, only use catalogs that support genre OR have no filter restrictions
// If genre is specified but catalog doesn't support genre filter, skip it
if (genre && !supportsGenre) {
continue;
}
const manifest = manifests.find(m => m.id === addon.id);
if (!manifest) continue;
const fetchPromise = (async () => {
try {
const filters = genre ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) {
const items = metas.slice(0, limit).map(meta => this.convertMetaToStreamingContent(meta));
return {
addonName: addon.name,
items
};
}
return null;
} catch (error) {
logger.error(`Discover failed for ${catalog.id} in addon ${addon.id}:`, error);
return null;
}
})();
catalogPromises.push(fetchPromise);
}
}
const catalogResults = await Promise.all(catalogPromises);
// Filter out null results and deduplicate by addon
const addonMap = new Map<string, StreamingContent[]>();
for (const result of catalogResults) {
if (result && result.items.length > 0) {
const existing = addonMap.get(result.addonName) || [];
// Merge items, avoiding duplicates
const existingIds = new Set(existing.map(item => `${item.type}:${item.id}`));
const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`));
addonMap.set(result.addonName, [...existing, ...newItems]);
}
}
// Convert map to array
for (const [addonName, items] of addonMap) {
results.push({ addonName, items: items.slice(0, limit) });
}
return results;
}
/**
* Discover content from a specific catalog with optional genre filter
* @param addonId - The addon ID
* @param catalogId - The catalog ID
* @param type - Content type (movie/series)
* @param genre - Optional genre filter
* @param limit - Maximum items to return
*/
async discoverContentFromCatalog(
addonId: string,
catalogId: string,
type: string,
genre?: string,
page: number = 1
): Promise<StreamingContent[]> {
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, page, filters);
if (metas && metas.length > 0) {
return metas.map(meta => this.convertMetaToStreamingContent(meta));
}
return [];
} catch (error) {
logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error);
return [];
}
}
async searchContent(query: string): Promise<StreamingContent[]> {
if (!query || query.trim().length < 2) {
return [];