mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-22 09:11:56 +00:00
2064 lines
65 KiB
TypeScript
2064 lines
65 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TextInput,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
useColorScheme,
|
|
SafeAreaView,
|
|
StatusBar,
|
|
Keyboard,
|
|
Dimensions,
|
|
ScrollView,
|
|
Animated as RNAnimated,
|
|
Pressable,
|
|
Platform,
|
|
Easing,
|
|
Modal,
|
|
} from 'react-native';
|
|
import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native';
|
|
import { NavigationProp } from '@react-navigation/native';
|
|
import { MaterialIcons, Feather } from '@expo/vector-icons';
|
|
import { catalogService, StreamingContent, GroupedSearchResults, AddonSearchResults } from '../services/catalogService';
|
|
import FastImage from '@d11/react-native-fast-image';
|
|
import debounce from 'lodash/debounce';
|
|
import { DropUpMenu } from '../components/home/DropUpMenu';
|
|
import { DeviceEventEmitter, Share } from 'react-native';
|
|
import { mmkvStorage } from '../services/mmkvStorage';
|
|
import Animated, {
|
|
FadeIn,
|
|
FadeOut,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withTiming,
|
|
interpolate,
|
|
withSpring,
|
|
withDelay,
|
|
} from 'react-native-reanimated';
|
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
|
import { logger } from '../utils/logger';
|
|
import { BlurView } from 'expo-blur';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useTheme } from '../contexts/ThemeContext';
|
|
import LoadingSpinner from '../components/common/LoadingSpinner';
|
|
import ScreenHeader from '../components/common/ScreenHeader';
|
|
import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
|
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
|
|
import { useSettings } from '../hooks/useSettings';
|
|
|
|
// Import extracted search components
|
|
import {
|
|
DiscoverCatalog,
|
|
BREAKPOINTS,
|
|
getDeviceType,
|
|
isTablet,
|
|
isLargeTablet,
|
|
isTV,
|
|
TAB_BAR_HEIGHT,
|
|
RECENT_SEARCHES_KEY,
|
|
MAX_RECENT_SEARCHES,
|
|
PLACEHOLDER_POSTER,
|
|
HORIZONTAL_ITEM_WIDTH,
|
|
HORIZONTAL_POSTER_HEIGHT,
|
|
POSTER_WIDTH,
|
|
POSTER_HEIGHT,
|
|
} from '../components/search/searchUtils';
|
|
import { SearchSkeletonLoader } from '../components/search/SearchSkeletonLoader';
|
|
import { SearchAnimation } from '../components/search/SearchAnimation';
|
|
import { SearchResultItem } from '../components/search/SearchResultItem';
|
|
import { RecentSearches } from '../components/search/RecentSearches';
|
|
|
|
const { width, height } = Dimensions.get('window');
|
|
|
|
// Re-export for local use (backward compatibility)
|
|
const deviceType = getDeviceType(width);
|
|
|
|
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
|
|
|
// Alias imported components for backward compatibility with existing code
|
|
const SkeletonLoader = SearchSkeletonLoader;
|
|
const SimpleSearchAnimation = SearchAnimation;
|
|
|
|
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
|
|
|
const SearchScreen = () => {
|
|
const { settings } = useSettings();
|
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
|
const isDarkMode = true;
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<GroupedSearchResults>({ byAddon: [], allResults: [] });
|
|
const [searching, setSearching] = useState(false);
|
|
const [searched, setSearched] = useState(false);
|
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
|
const [showRecent, setShowRecent] = useState(true);
|
|
const inputRef = useRef<TextInput>(null);
|
|
const insets = useSafeAreaInsets();
|
|
const { currentTheme } = useTheme();
|
|
// Live search handle
|
|
const liveSearchHandle = useRef<{ cancel: () => void; done: Promise<void> } | null>(null);
|
|
// Addon installation order map for stable section ordering
|
|
const addonOrderRankRef = useRef<Record<string, number>>({});
|
|
// Track if this is the initial mount to prevent unnecessary operations
|
|
const isInitialMount = useRef(true);
|
|
// Track mount status for async operations
|
|
const isMounted = useRef(true);
|
|
const scrollViewRef = useRef<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 [pendingDiscoverResults, setPendingDiscoverResults] = 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 });
|
|
}, []);
|
|
|
|
useScrollToTop('Search', scrollToTop);
|
|
|
|
useEffect(() => {
|
|
isMounted.current = true;
|
|
return () => {
|
|
isMounted.current = false;
|
|
};
|
|
}, []);
|
|
|
|
const handleShowMore = () => {
|
|
if (pendingDiscoverResults.length === 0) return;
|
|
|
|
// Show next batch of 300 items
|
|
const batchSize = 300;
|
|
const nextBatch = pendingDiscoverResults.slice(0, batchSize);
|
|
const remaining = pendingDiscoverResults.slice(batchSize);
|
|
|
|
setDiscoverResults(prev => [...prev, ...nextBatch]);
|
|
setPendingDiscoverResults(remaining);
|
|
};
|
|
|
|
// 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);
|
|
setPendingDiscoverResults([]);
|
|
try {
|
|
const results = await catalogService.discoverContentFromCatalog(
|
|
selectedCatalog.addonId,
|
|
selectedCatalog.catalogId,
|
|
selectedCatalog.type,
|
|
selectedDiscoverGenre || undefined,
|
|
1 // page 1
|
|
);
|
|
if (isMounted.current) {
|
|
if (results.length > 300) {
|
|
setDiscoverResults(results.slice(0, 300));
|
|
setPendingDiscoverResults(results.slice(300));
|
|
setHasMore(true);
|
|
} else {
|
|
setDiscoverResults(results);
|
|
setPendingDiscoverResults([]);
|
|
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 || pendingDiscoverResults.length > 0) 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) {
|
|
if (moreResults.length > 300) {
|
|
setDiscoverResults(prev => [...prev, ...moreResults.slice(0, 300)]);
|
|
setPendingDiscoverResults(moreResults.slice(300));
|
|
} else {
|
|
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);
|
|
const [isSaved, setIsSaved] = useState(false);
|
|
const [isWatched, setIsWatched] = useState(false);
|
|
const [refreshFlag, setRefreshFlag] = React.useState(false);
|
|
|
|
// Update isSaved and isWatched when selectedItem changes
|
|
useEffect(() => {
|
|
if (!selectedItem) return;
|
|
(async () => {
|
|
// Check if item is in library
|
|
const items = await catalogService.getLibraryItems();
|
|
const found = items.find((libItem: any) => libItem.id === selectedItem.id && libItem.type === selectedItem.type);
|
|
setIsSaved(!!found);
|
|
// Check watched status
|
|
const val = await mmkvStorage.getItem(`watched:${selectedItem.type}:${selectedItem.id}`);
|
|
setIsWatched(val === 'true');
|
|
})();
|
|
}, [selectedItem]);
|
|
// Animation values
|
|
const searchBarWidth = useSharedValue(width - 32);
|
|
const searchBarOpacity = useSharedValue(1);
|
|
const backButtonOpacity = useSharedValue(0);
|
|
|
|
// Force consistent status bar settings
|
|
useEffect(() => {
|
|
const applyStatusBarConfig = () => {
|
|
StatusBar.setBarStyle('light-content');
|
|
if (Platform.OS === 'android') {
|
|
StatusBar.setTranslucent(true);
|
|
StatusBar.setBackgroundColor('transparent');
|
|
}
|
|
};
|
|
|
|
applyStatusBarConfig();
|
|
|
|
// Re-apply on focus
|
|
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
|
|
return unsubscribe;
|
|
}, [navigation]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
navigation.setOptions({
|
|
headerShown: false,
|
|
});
|
|
}, [navigation]);
|
|
|
|
useEffect(() => {
|
|
loadRecentSearches();
|
|
|
|
// Cleanup function to cancel pending searches on unmount
|
|
return () => {
|
|
debouncedSearch.cancel();
|
|
};
|
|
}, []);
|
|
|
|
const animatedSearchBarStyle = useAnimatedStyle(() => {
|
|
return {
|
|
width: searchBarWidth.value,
|
|
opacity: searchBarOpacity.value,
|
|
};
|
|
});
|
|
|
|
const animatedBackButtonStyle = useAnimatedStyle(() => {
|
|
return {
|
|
opacity: backButtonOpacity.value,
|
|
transform: [
|
|
{
|
|
translateX: interpolate(
|
|
backButtonOpacity.value,
|
|
[0, 1],
|
|
[-20, 0]
|
|
)
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
const handleSearchFocus = () => {
|
|
// Animate search bar when focused
|
|
searchBarWidth.value = withTiming(width - 80);
|
|
backButtonOpacity.value = withTiming(1);
|
|
};
|
|
|
|
const handleSearchBlur = () => {
|
|
if (!query) {
|
|
// Only animate back if query is empty
|
|
searchBarWidth.value = withTiming(width - 32);
|
|
backButtonOpacity.value = withTiming(0);
|
|
}
|
|
};
|
|
|
|
const handleBackPress = () => {
|
|
Keyboard.dismiss();
|
|
if (query) {
|
|
setQuery('');
|
|
setResults({ byAddon: [], allResults: [] });
|
|
setSearched(false);
|
|
setShowRecent(true);
|
|
loadRecentSearches();
|
|
} else {
|
|
// Add a small delay to allow keyboard to dismiss smoothly before navigation
|
|
if (Platform.OS === 'android') {
|
|
setTimeout(() => {
|
|
navigation.goBack();
|
|
}, 100);
|
|
} else {
|
|
navigation.goBack();
|
|
}
|
|
}
|
|
};
|
|
|
|
const loadRecentSearches = async () => {
|
|
try {
|
|
const savedSearches = await mmkvStorage.getItem(RECENT_SEARCHES_KEY);
|
|
if (savedSearches) {
|
|
setRecentSearches(JSON.parse(savedSearches));
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to load recent searches:', error);
|
|
}
|
|
};
|
|
|
|
const saveRecentSearch = async (searchQuery: string) => {
|
|
try {
|
|
setRecentSearches(prevSearches => {
|
|
const newRecentSearches = [
|
|
searchQuery,
|
|
...prevSearches.filter(s => s !== searchQuery)
|
|
].slice(0, MAX_RECENT_SEARCHES);
|
|
|
|
// Save to AsyncStorage
|
|
mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
|
|
|
|
return newRecentSearches;
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to save recent search:', error);
|
|
}
|
|
};
|
|
|
|
// Create a stable debounced search function using useMemo
|
|
const debouncedSearch = useMemo(() => {
|
|
return debounce(async (searchQuery: string) => {
|
|
// Cancel any in-flight live search
|
|
liveSearchHandle.current?.cancel();
|
|
liveSearchHandle.current = null;
|
|
performLiveSearch(searchQuery);
|
|
}, 800);
|
|
}, []); // Empty dependency array - create once and never recreate
|
|
|
|
// Track focus state to strictly prevent updates when blurred (fixes Telemetry crash)
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
isMounted.current = true;
|
|
return () => {
|
|
isMounted.current = false;
|
|
// Cancel any active searches immediately on blur
|
|
if (liveSearchHandle.current) {
|
|
liveSearchHandle.current.cancel();
|
|
liveSearchHandle.current = null;
|
|
}
|
|
debouncedSearch.cancel();
|
|
};
|
|
}, [debouncedSearch])
|
|
);
|
|
|
|
// Live search implementation
|
|
const performLiveSearch = async (searchQuery: string) => {
|
|
// strict guard: don't search if unmounted or blurred
|
|
if (!isMounted.current) return;
|
|
|
|
if (!searchQuery || searchQuery.trim().length === 0) {
|
|
setResults({ byAddon: [], allResults: [] });
|
|
setSearching(false);
|
|
return;
|
|
}
|
|
|
|
setSearching(true);
|
|
setResults({ byAddon: [], allResults: [] });
|
|
// Reset order rank for new search
|
|
addonOrderRankRef.current = {};
|
|
|
|
try {
|
|
if (liveSearchHandle.current) {
|
|
liveSearchHandle.current.cancel();
|
|
}
|
|
|
|
// Pre-fetch addon list to establish a stable order rank
|
|
const addons = await catalogService.getAllAddons();
|
|
// ... (rank logic) ...
|
|
const rank: Record<string, number> = {};
|
|
let rankCounter = 0;
|
|
|
|
// Cinemeta first
|
|
rank['com.linvo.cinemeta'] = rankCounter++;
|
|
|
|
// Then others
|
|
addons.forEach(addon => {
|
|
if (addon.id !== 'com.linvo.cinemeta') {
|
|
rank[addon.id] = rankCounter++;
|
|
}
|
|
});
|
|
addonOrderRankRef.current = rank;
|
|
|
|
const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => {
|
|
// Prevent updates if component is unmounted or blurred
|
|
if (!isMounted.current) return;
|
|
|
|
// Append/update this addon section...
|
|
setResults(prev => {
|
|
// ... (existing update logic) ...
|
|
if (!isMounted.current) return prev; // Extra guard inside setter
|
|
|
|
const getRank = (id: string) => addonOrderRankRef.current[id] ?? Number.MAX_SAFE_INTEGER;
|
|
// ... (same logic as before) ...
|
|
const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId);
|
|
|
|
if (existingIndex >= 0) {
|
|
const copy = prev.byAddon.slice();
|
|
copy[existingIndex] = section;
|
|
return { byAddon: copy, allResults: prev.allResults };
|
|
}
|
|
|
|
// Insert new section
|
|
const insertRank = getRank(section.addonId);
|
|
let insertAt = prev.byAddon.length;
|
|
for (let i = 0; i < prev.byAddon.length; i++) {
|
|
if (getRank(prev.byAddon[i].addonId) > insertRank) {
|
|
insertAt = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const nextByAddon = [
|
|
...prev.byAddon.slice(0, insertAt),
|
|
section,
|
|
...prev.byAddon.slice(insertAt)
|
|
];
|
|
|
|
// Hide loading overlay once first section arrives
|
|
if (prev.byAddon.length === 0) {
|
|
setSearching(false);
|
|
}
|
|
|
|
return { byAddon: nextByAddon, allResults: prev.allResults };
|
|
});
|
|
|
|
try {
|
|
await saveRecentSearch(searchQuery);
|
|
} catch { }
|
|
});
|
|
|
|
liveSearchHandle.current = handle;
|
|
await handle.done;
|
|
|
|
if (isMounted.current) {
|
|
setSearching(false);
|
|
}
|
|
} catch (error) {
|
|
if (isMounted.current) {
|
|
console.error('Live search error:', error);
|
|
setSearching(false);
|
|
}
|
|
}
|
|
};
|
|
useEffect(() => {
|
|
// Skip initial mount to prevent unnecessary operations
|
|
if (isInitialMount.current) {
|
|
isInitialMount.current = false;
|
|
loadRecentSearches();
|
|
return;
|
|
}
|
|
|
|
if (query.trim() && query.trim().length >= 2) {
|
|
setSearching(true);
|
|
setSearched(true);
|
|
setShowRecent(false);
|
|
debouncedSearch(query);
|
|
} else if (query.trim().length < 2 && query.trim().length > 0) {
|
|
// Show that we're waiting for more characters
|
|
setSearching(false);
|
|
setSearched(false);
|
|
setShowRecent(false);
|
|
setResults({ byAddon: [], allResults: [] });
|
|
} else {
|
|
// Cancel any pending search when query is cleared
|
|
debouncedSearch.cancel();
|
|
liveSearchHandle.current?.cancel();
|
|
liveSearchHandle.current = null;
|
|
setResults({ byAddon: [], allResults: [] });
|
|
setSearched(false);
|
|
setSearching(false);
|
|
setShowRecent(true);
|
|
loadRecentSearches();
|
|
}
|
|
|
|
// Cleanup function to cancel pending searches
|
|
return () => {
|
|
debouncedSearch.cancel();
|
|
};
|
|
}, [query]); // Removed debouncedSearch since it's now stable with useMemo
|
|
|
|
const handleClearSearch = () => {
|
|
setQuery('');
|
|
liveSearchHandle.current?.cancel();
|
|
liveSearchHandle.current = null;
|
|
setResults({ byAddon: [], allResults: [] });
|
|
setSearched(false);
|
|
setShowRecent(true);
|
|
loadRecentSearches();
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const renderRecentSearches = () => {
|
|
if (!showRecent || recentSearches.length === 0) return null;
|
|
|
|
return (
|
|
<View
|
|
style={styles.recentSearchesContainer}
|
|
>
|
|
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
|
Recent Searches
|
|
</Text>
|
|
{recentSearches.map((search, index) => (
|
|
<TouchableOpacity
|
|
key={index}
|
|
style={styles.recentSearchItem}
|
|
onPress={() => {
|
|
setQuery(search);
|
|
Keyboard.dismiss();
|
|
}}
|
|
>
|
|
<MaterialIcons
|
|
name="history"
|
|
size={20}
|
|
color={currentTheme.colors.lightGray}
|
|
style={styles.recentSearchIcon}
|
|
/>
|
|
<Text style={[styles.recentSearchText, { color: currentTheme.colors.white }]}>
|
|
{search}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
const newRecentSearches = [...recentSearches];
|
|
newRecentSearches.splice(index, 1);
|
|
setRecentSearches(newRecentSearches);
|
|
mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
|
|
}}
|
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
style={styles.recentSearchDeleteButton}
|
|
>
|
|
<MaterialIcons name="close" size={16} color={currentTheme.colors.lightGray} />
|
|
</TouchableOpacity>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// 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 ? (
|
|
<FlatList
|
|
data={discoverResults}
|
|
keyExtractor={(item, index) => `discover-${item.id}-${index}`}
|
|
numColumns={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
|
|
key={isTV ? 'tv-6' : isLargeTablet ? 'ltab-5' : isTablet ? 'tab-4' : 'phone-3'}
|
|
columnWrapperStyle={styles.discoverGridRow}
|
|
contentContainerStyle={styles.discoverGridContent}
|
|
renderItem={({ item, index }) => (
|
|
<SearchResultItem
|
|
key={`discover-${item.id}-${index}`}
|
|
item={item}
|
|
index={index}
|
|
navigation={navigation}
|
|
setSelectedItem={setSelectedItem}
|
|
setMenuVisible={setMenuVisible}
|
|
currentTheme={currentTheme}
|
|
isGrid={true}
|
|
/>
|
|
)}
|
|
initialNumToRender={9}
|
|
maxToRenderPerBatch={6}
|
|
windowSize={5}
|
|
removeClippedSubviews={true}
|
|
scrollEnabled={false}
|
|
ListFooterComponent={
|
|
pendingDiscoverResults.length > 0 ? (
|
|
<TouchableOpacity
|
|
style={styles.showMoreButton}
|
|
onPress={handleShowMore}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[styles.showMoreButtonText, { color: currentTheme.colors.white }]}>
|
|
Show More ({pendingDiscoverResults.length})
|
|
</Text>
|
|
<MaterialIcons name="expand-more" size={20} color={currentTheme.colors.white} />
|
|
</TouchableOpacity>
|
|
) : loadingMore ? (
|
|
<View style={styles.loadingMoreContainer}>
|
|
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
|
</View>
|
|
) : null
|
|
}
|
|
/>
|
|
) : 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);
|
|
|
|
// Calculate dimensions based on poster shape
|
|
const { itemWidth, aspectRatio } = useMemo(() => {
|
|
const shape = item.posterShape || 'poster';
|
|
const baseHeight = HORIZONTAL_POSTER_HEIGHT;
|
|
|
|
let w = HORIZONTAL_ITEM_WIDTH;
|
|
let r = 2 / 3;
|
|
|
|
if (isGrid) {
|
|
// Grid Calculation: (Window Width - Padding) / Columns
|
|
// Padding: 16 (left) + 16 (right) = 32
|
|
// Gap: 12 (between items) * (columns - 1)
|
|
// Ensure minimum 3 columns on all devices
|
|
const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3;
|
|
const totalPadding = 32;
|
|
const totalGap = 12 * (Math.max(3, columns) - 1);
|
|
const availableWidth = width - totalPadding - totalGap;
|
|
w = availableWidth / Math.max(3, columns);
|
|
} else {
|
|
if (shape === 'landscape') {
|
|
r = 16 / 9;
|
|
w = baseHeight * r;
|
|
} else if (shape === 'square') {
|
|
r = 1;
|
|
w = baseHeight;
|
|
}
|
|
}
|
|
return { itemWidth: w, aspectRatio: r };
|
|
}, [item.posterShape, isGrid]);
|
|
|
|
React.useEffect(() => {
|
|
const updateWatched = () => {
|
|
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
|
|
};
|
|
updateWatched();
|
|
const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched);
|
|
return () => sub.remove();
|
|
}, [item.id, item.type]);
|
|
React.useEffect(() => {
|
|
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
|
|
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type);
|
|
setInLibrary(!!found);
|
|
});
|
|
return () => unsubscribe();
|
|
}, [item.id, item.type]);
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.horizontalItem,
|
|
{ width: itemWidth },
|
|
isGrid && styles.discoverGridItem
|
|
]}
|
|
onPress={() => {
|
|
navigation.navigate('Metadata', {
|
|
id: item.id,
|
|
type: item.type,
|
|
addonId: item.addonId
|
|
});
|
|
}}
|
|
onLongPress={() => {
|
|
setSelectedItem(item);
|
|
setMenuVisible(true);
|
|
// Do NOT toggle refreshFlag here
|
|
}}
|
|
delayLongPress={300}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={[styles.horizontalItemPosterContainer, {
|
|
width: itemWidth,
|
|
height: undefined, // Let aspect ratio control height
|
|
aspectRatio: aspectRatio,
|
|
backgroundColor: currentTheme.colors.darkBackground,
|
|
borderRadius: settings.posterBorderRadius ?? 12,
|
|
}]}>
|
|
<FastImage
|
|
source={{
|
|
uri: item.poster || PLACEHOLDER_POSTER,
|
|
priority: FastImage.priority.low,
|
|
cache: FastImage.cacheControl.immutable,
|
|
}}
|
|
style={[styles.horizontalItemPoster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
|
|
resizeMode={FastImage.resizeMode.cover}
|
|
/>
|
|
{/* Bookmark and watched icons top right, bookmark to the left of watched */}
|
|
{inLibrary && (
|
|
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }]}>
|
|
<Feather name="bookmark" size={16} color={currentTheme.colors.white} />
|
|
</View>
|
|
)}
|
|
{watched && (
|
|
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }]}>
|
|
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
|
|
</View>
|
|
)}
|
|
{/* Rating removed per user request */}
|
|
</View>
|
|
<Text
|
|
style={[
|
|
styles.horizontalItemTitle,
|
|
{
|
|
color: currentTheme.colors.white,
|
|
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14,
|
|
lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18,
|
|
}
|
|
]}
|
|
numberOfLines={2}
|
|
>
|
|
{item.name}
|
|
</Text>
|
|
{item.year && (
|
|
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}>
|
|
{item.year}
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
const hasResultsToShow = useMemo(() => {
|
|
return results.byAddon.length > 0;
|
|
}, [results]);
|
|
|
|
// Memoized addon section to prevent re-rendering unchanged sections
|
|
const AddonSection = React.memo(({
|
|
addonGroup,
|
|
addonIndex
|
|
}: {
|
|
addonGroup: AddonSearchResults;
|
|
addonIndex: number;
|
|
}) => {
|
|
const movieResults = useMemo(() =>
|
|
addonGroup.results.filter(item => item.type === 'movie'),
|
|
[addonGroup.results]
|
|
);
|
|
const seriesResults = useMemo(() =>
|
|
addonGroup.results.filter(item => item.type === 'series'),
|
|
[addonGroup.results]
|
|
);
|
|
const otherResults = useMemo(() =>
|
|
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
|
|
[addonGroup.results]
|
|
);
|
|
|
|
return (
|
|
<View>
|
|
{/* Addon Header */}
|
|
<View style={styles.addonHeaderContainer}>
|
|
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
|
|
{addonGroup.addonName}
|
|
</Text>
|
|
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
|
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
|
|
{addonGroup.results.length}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Movies */}
|
|
{movieResults.length > 0 && (
|
|
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
|
<Text style={[
|
|
styles.carouselSubtitle,
|
|
{
|
|
color: currentTheme.colors.lightGray,
|
|
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
|
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
|
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
|
}
|
|
]}>
|
|
Movies ({movieResults.length})
|
|
</Text>
|
|
<FlatList
|
|
data={movieResults}
|
|
renderItem={({ item, index }) => (
|
|
<SearchResultItem
|
|
item={item}
|
|
index={index}
|
|
navigation={navigation}
|
|
setSelectedItem={setSelectedItem}
|
|
setMenuVisible={setMenuVisible}
|
|
currentTheme={currentTheme}
|
|
/>
|
|
)}
|
|
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.horizontalListContent}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* TV Shows */}
|
|
{seriesResults.length > 0 && (
|
|
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
|
<Text style={[
|
|
styles.carouselSubtitle,
|
|
{
|
|
color: currentTheme.colors.lightGray,
|
|
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
|
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
|
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
|
}
|
|
]}>
|
|
TV Shows ({seriesResults.length})
|
|
</Text>
|
|
<FlatList
|
|
data={seriesResults}
|
|
renderItem={({ item, index }) => (
|
|
<SearchResultItem
|
|
item={item}
|
|
index={index}
|
|
navigation={navigation}
|
|
setSelectedItem={setSelectedItem}
|
|
setMenuVisible={setMenuVisible}
|
|
currentTheme={currentTheme}
|
|
/>
|
|
)}
|
|
keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.horizontalListContent}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* Other types */}
|
|
{otherResults.length > 0 && (
|
|
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
|
<Text style={[
|
|
styles.carouselSubtitle,
|
|
{
|
|
color: currentTheme.colors.lightGray,
|
|
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
|
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
|
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
|
}
|
|
]}>
|
|
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
|
|
</Text>
|
|
<FlatList
|
|
data={otherResults}
|
|
renderItem={({ item, index }) => (
|
|
<SearchResultItem
|
|
item={item}
|
|
index={index}
|
|
navigation={navigation}
|
|
setSelectedItem={setSelectedItem}
|
|
setMenuVisible={setMenuVisible}
|
|
currentTheme={currentTheme}
|
|
/>
|
|
)}
|
|
keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.horizontalListContent}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}, (prev, next) => {
|
|
// Only re-render if this section's reference changed
|
|
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
|
|
});
|
|
|
|
// Set up listeners for watched status and library updates
|
|
// These will trigger re-renders in individual SearchResultItem components
|
|
useEffect(() => {
|
|
const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', () => {
|
|
// Individual items will handle their own watched status updates
|
|
// No need to force a full re-render of all results
|
|
});
|
|
const librarySub = catalogService.subscribeToLibraryUpdates(() => {
|
|
// Individual items will handle their own library status updates
|
|
// No need to force a full re-render of all results
|
|
});
|
|
|
|
return () => {
|
|
watchedSub.remove();
|
|
librarySub();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<View
|
|
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
|
|
>
|
|
<StatusBar
|
|
barStyle="light-content"
|
|
backgroundColor="transparent"
|
|
translucent
|
|
/>
|
|
|
|
{/* ScreenHeader Component */}
|
|
<ScreenHeader
|
|
title="Search"
|
|
isTablet={isTV || isLargeTablet || isTablet}
|
|
>
|
|
{/* Search Bar */}
|
|
<View style={styles.searchBarContainer}>
|
|
<View style={[
|
|
styles.searchBarWrapper,
|
|
{ width: '100%' }
|
|
]}>
|
|
<View style={[
|
|
styles.searchBar,
|
|
{
|
|
backgroundColor: currentTheme.colors.elevation2,
|
|
borderColor: 'rgba(255,255,255,0.1)',
|
|
borderWidth: 1,
|
|
}
|
|
]}>
|
|
<MaterialIcons
|
|
name="search"
|
|
size={24}
|
|
color={currentTheme.colors.lightGray}
|
|
style={styles.searchIcon}
|
|
/>
|
|
<TextInput
|
|
style={[
|
|
styles.searchInput,
|
|
{ color: currentTheme.colors.white }
|
|
]}
|
|
placeholder="Search movies, shows..."
|
|
placeholderTextColor={currentTheme.colors.lightGray}
|
|
value={query}
|
|
onChangeText={setQuery}
|
|
returnKeyType="search"
|
|
keyboardAppearance="dark"
|
|
ref={inputRef}
|
|
/>
|
|
{query.length > 0 && (
|
|
<TouchableOpacity
|
|
onPress={handleClearSearch}
|
|
style={styles.clearButton}
|
|
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
|
>
|
|
<MaterialIcons
|
|
name="close"
|
|
size={20}
|
|
color={currentTheme.colors.lightGray}
|
|
/>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</ScreenHeader>
|
|
|
|
{/* Content Container */}
|
|
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
|
{searching ? (
|
|
<View style={styles.loadingOverlay} pointerEvents="none">
|
|
<LoadingSpinner
|
|
size="large"
|
|
offsetY={-60}
|
|
/>
|
|
</View>
|
|
) : query.trim().length === 1 ? (
|
|
<View
|
|
style={styles.emptyContainer}
|
|
>
|
|
<MaterialIcons
|
|
name="search"
|
|
size={64}
|
|
color={currentTheme.colors.lightGray}
|
|
/>
|
|
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
|
Keep typing...
|
|
</Text>
|
|
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
|
Type at least 2 characters to search
|
|
</Text>
|
|
</View>
|
|
) : searched && !hasResultsToShow ? (
|
|
<View
|
|
style={styles.emptyContainer}
|
|
>
|
|
<MaterialIcons
|
|
name="search-off"
|
|
size={64}
|
|
color={currentTheme.colors.lightGray}
|
|
/>
|
|
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
|
No results found
|
|
</Text>
|
|
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
|
Try different keywords or check your spelling
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<ScrollView
|
|
ref={scrollViewRef}
|
|
style={styles.scrollView}
|
|
contentContainerStyle={styles.scrollViewContent}
|
|
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 || pendingDiscoverResults.length > 0) 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
|
|
key={addonGroup.addonId}
|
|
addonGroup={addonGroup}
|
|
addonIndex={addonIndex}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
{/* DropUpMenu integration for search results */}
|
|
{selectedItem && (
|
|
<DropUpMenu
|
|
visible={menuVisible}
|
|
onClose={() => setMenuVisible(false)}
|
|
item={selectedItem}
|
|
isSaved={isSaved}
|
|
isWatched={isWatched}
|
|
onOptionSelect={async (option: string) => {
|
|
if (!selectedItem) return;
|
|
switch (option) {
|
|
case 'share': {
|
|
let url = '';
|
|
if (selectedItem.id) {
|
|
url = `https://www.imdb.com/title/${selectedItem.id}/`;
|
|
}
|
|
const message = `${selectedItem.name}\n${url}`;
|
|
Share.share({ message, url, title: selectedItem.name });
|
|
break;
|
|
}
|
|
case 'library': {
|
|
if (isSaved) {
|
|
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
|
|
setIsSaved(false);
|
|
} else {
|
|
await catalogService.addToLibrary(selectedItem);
|
|
setIsSaved(true);
|
|
}
|
|
break;
|
|
}
|
|
case 'watched': {
|
|
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
|
|
const newWatched = !isWatched;
|
|
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
|
|
setIsWatched(newWatched);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Catalog Selection Bottom Sheet */}
|
|
<BottomSheetModal
|
|
ref={catalogSheetRef}
|
|
index={0}
|
|
snapPoints={catalogSnapPoints}
|
|
enableDynamicSizing={false}
|
|
enablePanDownToClose={true}
|
|
backdropComponent={renderBackdrop}
|
|
android_keyboardInputMode="adjustResize"
|
|
animateOnMount={true}
|
|
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}
|
|
android_keyboardInputMode="adjustResize"
|
|
animateOnMount={true}
|
|
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>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
contentContainer: {
|
|
flex: 1,
|
|
paddingTop: 0,
|
|
},
|
|
searchBarContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 8,
|
|
height: 48,
|
|
},
|
|
searchBarWrapper: {
|
|
flex: 1,
|
|
height: 48,
|
|
},
|
|
searchBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderRadius: 12,
|
|
paddingHorizontal: 16,
|
|
height: '100%',
|
|
shadowColor: "#000",
|
|
shadowOffset: {
|
|
width: 0,
|
|
height: 2,
|
|
},
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 3.84,
|
|
elevation: 5,
|
|
},
|
|
searchIcon: {
|
|
marginRight: 12,
|
|
},
|
|
searchInput: {
|
|
flex: 1,
|
|
fontSize: 16,
|
|
height: '100%',
|
|
},
|
|
clearButton: {
|
|
padding: 4,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
scrollViewContent: {
|
|
paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav
|
|
paddingHorizontal: 0,
|
|
},
|
|
carouselContainer: {
|
|
marginBottom: isTablet ? 32 : 24,
|
|
},
|
|
carouselTitle: {
|
|
fontSize: isTablet ? 20 : 18,
|
|
fontWeight: '700',
|
|
marginBottom: isTablet ? 16 : 12,
|
|
paddingHorizontal: 16,
|
|
},
|
|
carouselSubtitle: {
|
|
fontSize: isTablet ? 16 : 14,
|
|
fontWeight: '600',
|
|
marginBottom: isTablet ? 12 : 8,
|
|
paddingHorizontal: 16,
|
|
},
|
|
addonHeaderContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: isTablet ? 16 : 12,
|
|
marginTop: isTablet ? 24 : 16,
|
|
marginBottom: isTablet ? 8 : 4,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
|
},
|
|
addonHeaderIcon: {
|
|
// removed icon
|
|
},
|
|
addonHeaderText: {
|
|
fontSize: isTablet ? 18 : 16,
|
|
fontWeight: '700',
|
|
flex: 1,
|
|
},
|
|
addonHeaderBadge: {
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
borderRadius: 12,
|
|
},
|
|
addonHeaderBadgeText: {
|
|
fontSize: isTablet ? 12 : 11,
|
|
fontWeight: '600',
|
|
},
|
|
horizontalListContent: {
|
|
paddingHorizontal: 16,
|
|
},
|
|
horizontalItem: {
|
|
width: HORIZONTAL_ITEM_WIDTH,
|
|
marginRight: 16,
|
|
},
|
|
horizontalItemPosterContainer: {
|
|
width: HORIZONTAL_ITEM_WIDTH,
|
|
height: HORIZONTAL_POSTER_HEIGHT,
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
marginBottom: 8,
|
|
borderWidth: 1.5,
|
|
borderColor: 'rgba(255,255,255,0.15)',
|
|
// Consistent shadow/elevation matching ContentItem
|
|
elevation: Platform.OS === 'android' ? 1 : 0,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.05,
|
|
shadowRadius: 1,
|
|
},
|
|
horizontalItemPoster: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
horizontalItemTitle: {
|
|
fontSize: isTablet ? 12 : 14,
|
|
fontWeight: '600',
|
|
lineHeight: isTablet ? 16 : 18,
|
|
textAlign: 'left',
|
|
},
|
|
yearText: {
|
|
fontSize: isTablet ? 10 : 12,
|
|
marginTop: 2,
|
|
},
|
|
recentSearchesContainer: {
|
|
paddingHorizontal: 16,
|
|
paddingBottom: isTablet ? 24 : 16,
|
|
paddingTop: isTablet ? 12 : 8,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(255,255,255,0.05)',
|
|
marginBottom: isTablet ? 16 : 8,
|
|
},
|
|
recentSearchItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: isTablet ? 12 : 10,
|
|
paddingHorizontal: 16,
|
|
marginVertical: 1,
|
|
},
|
|
recentSearchIcon: {
|
|
marginRight: 12,
|
|
},
|
|
recentSearchText: {
|
|
fontSize: 16,
|
|
flex: 1,
|
|
},
|
|
recentSearchDeleteButton: {
|
|
padding: 4,
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
loadingOverlay: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
zIndex: 5,
|
|
},
|
|
loadingText: {
|
|
marginTop: 16,
|
|
fontSize: 16,
|
|
},
|
|
emptyContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
paddingHorizontal: isTablet ? 64 : 32,
|
|
paddingBottom: isTablet ? 120 : 100,
|
|
},
|
|
emptyText: {
|
|
fontSize: 18,
|
|
fontWeight: 'bold',
|
|
marginTop: 16,
|
|
marginBottom: 8,
|
|
},
|
|
emptySubtext: {
|
|
fontSize: 14,
|
|
textAlign: 'center',
|
|
lineHeight: 20,
|
|
},
|
|
skeletonContainer: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
paddingHorizontal: 12,
|
|
paddingTop: 16,
|
|
justifyContent: 'space-between',
|
|
},
|
|
skeletonVerticalItem: {
|
|
flexDirection: 'row',
|
|
marginBottom: 16,
|
|
},
|
|
skeletonPoster: {
|
|
width: POSTER_WIDTH,
|
|
height: POSTER_HEIGHT,
|
|
borderRadius: 12,
|
|
},
|
|
skeletonItemDetails: {
|
|
flex: 1,
|
|
marginLeft: 16,
|
|
justifyContent: 'center',
|
|
},
|
|
skeletonMetaRow: {
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
marginTop: 8,
|
|
},
|
|
skeletonTitle: {
|
|
height: 20,
|
|
width: '80%',
|
|
marginBottom: 8,
|
|
borderRadius: 4,
|
|
},
|
|
skeletonMeta: {
|
|
height: 14,
|
|
width: '30%',
|
|
borderRadius: 4,
|
|
},
|
|
skeletonSectionHeader: {
|
|
height: 24,
|
|
width: '40%',
|
|
marginBottom: 16,
|
|
borderRadius: 4,
|
|
},
|
|
ratingContainer: {
|
|
position: 'absolute',
|
|
bottom: 8,
|
|
right: 8,
|
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 3,
|
|
borderRadius: 4,
|
|
},
|
|
ratingText: {
|
|
fontSize: isTablet ? 9 : 10,
|
|
fontWeight: '700',
|
|
marginLeft: 2,
|
|
},
|
|
simpleAnimationContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
simpleAnimationContent: {
|
|
alignItems: 'center',
|
|
},
|
|
spinnerContainer: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: 16,
|
|
shadowColor: "#000",
|
|
shadowOffset: {
|
|
width: 0,
|
|
height: 2,
|
|
},
|
|
shadowOpacity: 0.2,
|
|
shadowRadius: 4,
|
|
elevation: 3,
|
|
},
|
|
simpleAnimationText: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
watchedIndicator: {
|
|
position: 'absolute',
|
|
top: 8,
|
|
right: 8,
|
|
borderRadius: 12,
|
|
padding: 2,
|
|
zIndex: 2,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
libraryBadge: {
|
|
position: 'absolute',
|
|
top: 8,
|
|
left: 8,
|
|
borderRadius: 8,
|
|
padding: 4,
|
|
zIndex: 2,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
// Discover section styles
|
|
discoverContainer: {
|
|
paddingTop: isTablet ? 16 : 12,
|
|
paddingBottom: isTablet ? 24 : 16,
|
|
},
|
|
discoverHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
marginBottom: isTablet ? 16 : 12,
|
|
gap: 8,
|
|
},
|
|
discoverTitle: {
|
|
fontSize: isTablet ? 22 : 20,
|
|
fontWeight: '700',
|
|
},
|
|
discoverTypeContainer: {
|
|
flexDirection: 'row',
|
|
paddingHorizontal: 16,
|
|
marginBottom: isTablet ? 16 : 12,
|
|
gap: 12,
|
|
},
|
|
discoverTypeButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
borderRadius: 20,
|
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
gap: 6,
|
|
},
|
|
discoverTypeText: {
|
|
fontSize: isTablet ? 15 : 14,
|
|
fontWeight: '600',
|
|
},
|
|
discoverGenreScroll: {
|
|
marginBottom: isTablet ? 20 : 16,
|
|
},
|
|
discoverGenreContent: {
|
|
paddingHorizontal: 16,
|
|
gap: 8,
|
|
},
|
|
discoverGenreChip: {
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
borderRadius: 16,
|
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
marginRight: 8,
|
|
},
|
|
discoverGenreChipActive: {
|
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
},
|
|
discoverGenreText: {
|
|
fontSize: isTablet ? 14 : 13,
|
|
fontWeight: '500',
|
|
color: 'rgba(255,255,255,0.7)',
|
|
},
|
|
discoverGenreTextActive: {
|
|
color: '#FFFFFF',
|
|
fontWeight: '600',
|
|
},
|
|
discoverLoadingContainer: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: 60,
|
|
},
|
|
discoverLoadingText: {
|
|
marginTop: 12,
|
|
fontSize: 14,
|
|
},
|
|
discoverAddonSection: {
|
|
marginBottom: isTablet ? 28 : 20,
|
|
},
|
|
discoverAddonHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
marginBottom: isTablet ? 12 : 8,
|
|
},
|
|
discoverAddonName: {
|
|
fontSize: isTablet ? 16 : 15,
|
|
fontWeight: '600',
|
|
flex: 1,
|
|
},
|
|
discoverAddonBadge: {
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 3,
|
|
borderRadius: 10,
|
|
},
|
|
discoverAddonBadgeText: {
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
},
|
|
discoverEmptyContainer: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: 60,
|
|
paddingHorizontal: 32,
|
|
},
|
|
discoverEmptyText: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
marginTop: 12,
|
|
textAlign: 'center',
|
|
},
|
|
discoverEmptySubtext: {
|
|
fontSize: 14,
|
|
marginTop: 4,
|
|
textAlign: 'center',
|
|
},
|
|
discoverGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
paddingHorizontal: 16,
|
|
gap: 12, // vertical and horizontal gap
|
|
},
|
|
discoverGridRow: {
|
|
justifyContent: 'flex-start',
|
|
gap: 12,
|
|
},
|
|
discoverGridContent: {
|
|
paddingHorizontal: 16,
|
|
paddingBottom: 16,
|
|
},
|
|
discoverGridItem: {
|
|
marginRight: 0, // Override horizontalItem margin
|
|
marginBottom: 12,
|
|
},
|
|
loadingMoreContainer: {
|
|
width: '100%',
|
|
paddingVertical: 16,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
// New chip-based discover styles
|
|
discoverChipsScroll: {
|
|
marginBottom: isTablet ? 12 : 10,
|
|
flexGrow: 0,
|
|
},
|
|
discoverChipsContent: {
|
|
paddingHorizontal: 16,
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
},
|
|
discoverSelectorChip: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
borderRadius: 20,
|
|
gap: 6,
|
|
},
|
|
discoverSelectorText: {
|
|
fontSize: isTablet ? 14 : 13,
|
|
fontWeight: '600',
|
|
},
|
|
discoverFilterSummary: {
|
|
paddingHorizontal: 16,
|
|
marginBottom: isTablet ? 16 : 12,
|
|
},
|
|
discoverFilterSummaryText: {
|
|
fontSize: 12,
|
|
fontWeight: '500',
|
|
},
|
|
// Bottom sheet styles
|
|
bottomSheetHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 16,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
|
},
|
|
bottomSheetTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
},
|
|
bottomSheetContent: {
|
|
paddingHorizontal: 12,
|
|
paddingBottom: 40,
|
|
},
|
|
bottomSheetItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 12,
|
|
marginVertical: 2,
|
|
},
|
|
bottomSheetItemSelected: {
|
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
},
|
|
bottomSheetItemIcon: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginRight: 12,
|
|
},
|
|
bottomSheetItemContent: {
|
|
flex: 1,
|
|
},
|
|
bottomSheetItemTitle: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
bottomSheetItemSubtitle: {
|
|
fontSize: 13,
|
|
marginTop: 2,
|
|
},
|
|
showMoreButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 24,
|
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
borderRadius: 8,
|
|
marginVertical: 20,
|
|
alignSelf: 'center',
|
|
},
|
|
showMoreButtonText: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
marginRight: 8,
|
|
},
|
|
});
|
|
|
|
export default SearchScreen;
|