mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-08 03:00:41 +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,
|
CustomNavigationDarkTheme,
|
||||||
CustomDarkTheme
|
CustomDarkTheme
|
||||||
} from './src/navigation/AppNavigator';
|
} from './src/navigation/AppNavigator';
|
||||||
|
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
import { CatalogProvider } from './src/contexts/CatalogContext';
|
import { CatalogProvider } from './src/contexts/CatalogContext';
|
||||||
import { GenreProvider } from './src/contexts/GenreContext';
|
import { GenreProvider } from './src/contexts/GenreContext';
|
||||||
|
|
@ -245,19 +246,21 @@ const ThemedApp = () => {
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<GenreProvider>
|
<BottomSheetModalProvider>
|
||||||
<CatalogProvider>
|
<GenreProvider>
|
||||||
<TraktProvider>
|
<CatalogProvider>
|
||||||
<ThemeProvider>
|
<TraktProvider>
|
||||||
<TrailerProvider>
|
<ThemeProvider>
|
||||||
<ToastProvider>
|
<TrailerProvider>
|
||||||
<ThemedApp />
|
<ToastProvider>
|
||||||
</ToastProvider>
|
<ThemedApp />
|
||||||
</TrailerProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</TrailerProvider>
|
||||||
</TraktProvider>
|
</ThemeProvider>
|
||||||
</CatalogProvider>
|
</TraktProvider>
|
||||||
</GenreProvider>
|
</CatalogProvider>
|
||||||
|
</GenreProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ export interface AppSettings {
|
||||||
videoPlayerEngine: 'auto' | 'mpv'; // Video player engine: auto (ExoPlayer primary, MPV fallback) or mpv (MPV only)
|
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)
|
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)
|
gpuMode: 'gpu' | 'gpu-next'; // GPU rendering mode: gpu (standard) or gpu-next (advanced HDR/color)
|
||||||
|
showDiscover: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
|
@ -157,6 +158,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
videoPlayerEngine: 'auto', // Default to auto (ExoPlayer primary, MPV fallback)
|
videoPlayerEngine: 'auto', // Default to auto (ExoPlayer primary, MPV fallback)
|
||||||
decoderMode: 'auto', // Default to auto (best compatibility and performance)
|
decoderMode: 'auto', // Default to auto (best compatibility and performance)
|
||||||
gpuMode: 'gpu', // Default to gpu (gpu-next for advanced HDR)
|
gpuMode: 'gpu', // Default to gpu (gpu-next for advanced HDR)
|
||||||
|
showDiscover: true, // Show Discover section in SearchScreen
|
||||||
};
|
};
|
||||||
|
|
||||||
const SETTINGS_STORAGE_KEY = 'app_settings';
|
const SETTINGS_STORAGE_KEY = 'app_settings';
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Pressable,
|
Pressable,
|
||||||
Platform,
|
Platform,
|
||||||
Easing,
|
Easing,
|
||||||
|
Modal,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native';
|
import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native';
|
||||||
import { NavigationProp } 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 LoadingSpinner from '../components/common/LoadingSpinner';
|
||||||
import ScreenHeader from '../components/common/ScreenHeader';
|
import ScreenHeader from '../components/common/ScreenHeader';
|
||||||
import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
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');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
|
@ -219,6 +232,7 @@ const SimpleSearchAnimation = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const SearchScreen = () => {
|
const SearchScreen = () => {
|
||||||
|
const { settings } = useSettings();
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const isDarkMode = true;
|
const isDarkMode = true;
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
|
@ -240,6 +254,28 @@ const SearchScreen = () => {
|
||||||
const isMounted = useRef(true);
|
const isMounted = useRef(true);
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
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
|
// Scroll to top handler
|
||||||
const scrollToTop = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||||
|
|
@ -253,6 +289,112 @@ const SearchScreen = () => {
|
||||||
isMounted.current = false;
|
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
|
// DropUpMenu state
|
||||||
const [menuVisible, setMenuVisible] = useState(false);
|
const [menuVisible, setMenuVisible] = useState(false);
|
||||||
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
|
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;
|
item: StreamingContent;
|
||||||
index: number;
|
index: number;
|
||||||
navigation: any;
|
navigation: any;
|
||||||
setSelectedItem: (item: StreamingContent) => void;
|
setSelectedItem: (item: StreamingContent) => void;
|
||||||
setMenuVisible: (visible: boolean) => void;
|
setMenuVisible: (visible: boolean) => void;
|
||||||
currentTheme: any;
|
currentTheme: any;
|
||||||
|
isGrid?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary);
|
const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary);
|
||||||
const [watched, setWatched] = React.useState(false);
|
const [watched, setWatched] = React.useState(false);
|
||||||
|
|
@ -633,15 +957,26 @@ const SearchScreen = () => {
|
||||||
let w = HORIZONTAL_ITEM_WIDTH;
|
let w = HORIZONTAL_ITEM_WIDTH;
|
||||||
let r = 2 / 3;
|
let r = 2 / 3;
|
||||||
|
|
||||||
if (shape === 'landscape') {
|
if (isGrid) {
|
||||||
r = 16 / 9;
|
// Grid Calculation: (Window Width - Padding) / Columns
|
||||||
w = baseHeight * r;
|
// Padding: 16 (left) + 16 (right) = 32
|
||||||
} else if (shape === 'square') {
|
// Gap: 12 (between items) * (columns - 1)
|
||||||
r = 1;
|
const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3;
|
||||||
w = baseHeight;
|
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 };
|
return { itemWidth: w, aspectRatio: r };
|
||||||
}, [item.posterShape]);
|
}, [item.posterShape, isGrid]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const updateWatched = () => {
|
const updateWatched = () => {
|
||||||
|
|
@ -661,7 +996,11 @@ const SearchScreen = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.horizontalItem, { width: itemWidth }]}
|
style={[
|
||||||
|
styles.horizontalItem,
|
||||||
|
{ width: itemWidth },
|
||||||
|
isGrid && styles.discoverGridItem
|
||||||
|
]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigation.navigate('Metadata', { id: item.id, type: item.type });
|
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'} />
|
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{item.imdbRating && (
|
{/* Rating removed per user request */}
|
||||||
<View style={styles.ratingContainer}>
|
|
||||||
<MaterialIcons name="star" size={12} color="#FFC107" />
|
|
||||||
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
|
|
||||||
{item.imdbRating}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
|
@ -1009,8 +1341,22 @@ const SearchScreen = () => {
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
onScrollBeginDrag={Keyboard.dismiss}
|
onScrollBeginDrag={Keyboard.dismiss}
|
||||||
showsVerticalScrollIndicator={false}
|
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() && renderRecentSearches()}
|
||||||
|
{!query.trim() && settings.showDiscover && renderDiscoverSection()}
|
||||||
|
|
||||||
{/* Render results grouped by addon using memoized component */}
|
{/* Render results grouped by addon using memoized component */}
|
||||||
{results.byAddon.map((addonGroup, addonIndex) => (
|
{results.byAddon.map((addonGroup, addonIndex) => (
|
||||||
<AddonSection
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1164,12 +1714,11 @@ const styles = StyleSheet.create({
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
horizontalListContent: {
|
horizontalListContent: {
|
||||||
paddingHorizontal: isTablet ? 16 : 12,
|
paddingHorizontal: 16,
|
||||||
paddingRight: isTablet ? 12 : 8,
|
|
||||||
},
|
},
|
||||||
horizontalItem: {
|
horizontalItem: {
|
||||||
width: HORIZONTAL_ITEM_WIDTH,
|
width: HORIZONTAL_ITEM_WIDTH,
|
||||||
marginRight: isTablet ? 16 : 12,
|
marginRight: 16,
|
||||||
},
|
},
|
||||||
horizontalItemPosterContainer: {
|
horizontalItemPosterContainer: {
|
||||||
width: HORIZONTAL_ITEM_WIDTH,
|
width: HORIZONTAL_ITEM_WIDTH,
|
||||||
|
|
@ -1360,6 +1909,211 @@ const styles = StyleSheet.create({
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
backgroundColor: 'transparent',
|
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;
|
export default SearchScreen;
|
||||||
|
|
|
||||||
|
|
@ -594,6 +594,18 @@ const SettingsScreen: React.FC = () => {
|
||||||
onPress={() => navigation.navigate('HomeScreenSettings')}
|
onPress={() => navigation.navigate('HomeScreenSettings')}
|
||||||
isTablet={isTablet}
|
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
|
<SettingItem
|
||||||
title="Continue Watching"
|
title="Continue Watching"
|
||||||
description="Cache and playback behavior"
|
description="Cache and playback behavior"
|
||||||
|
|
|
||||||
|
|
@ -1052,6 +1052,187 @@ class CatalogService {
|
||||||
return this.recentContent;
|
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[]> {
|
async searchContent(query: string): Promise<StreamingContent[]> {
|
||||||
if (!query || query.trim().length < 2) {
|
if (!query || query.trim().length < 2) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue