mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
discover screen init
This commit is contained in:
parent
18e90397d9
commit
a30fa604d7
5 changed files with 985 additions and 33 deletions
29
App.tsx
29
App.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue