mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-10 20:10:54 +00:00
Remove search components: EmptyResults, SearchBar, RecentSearches, ResultsCarousel, SkeletonLoader, and SearchResultItem. Refactor SearchScreen to integrate their functionality directly, enhancing code organization and reducing component complexity.
This commit is contained in:
parent
f457ade071
commit
5e81a14ebb
9 changed files with 354 additions and 540 deletions
|
|
@ -1,54 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
|
||||||
import { colors } from '../../styles';
|
|
||||||
|
|
||||||
interface EmptyResultsProps {
|
|
||||||
isDarkMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EmptyResults: React.FC<EmptyResultsProps> = ({ isDarkMode = true }) => {
|
|
||||||
return (
|
|
||||||
<View style={styles.emptyContainer}>
|
|
||||||
<MaterialIcons
|
|
||||||
name="search-off"
|
|
||||||
size={64}
|
|
||||||
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
|
||||||
/>
|
|
||||||
<Text style={[
|
|
||||||
styles.emptyText,
|
|
||||||
{ color: isDarkMode ? colors.white : colors.black }
|
|
||||||
]}>
|
|
||||||
No results found
|
|
||||||
</Text>
|
|
||||||
<Text style={[
|
|
||||||
styles.emptySubtext,
|
|
||||||
{ color: isDarkMode ? colors.lightGray : colors.mediumGray }
|
|
||||||
]}>
|
|
||||||
Try different keywords or check your spelling
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
emptyContainer: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 32,
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginTop: 16,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
emptySubtext: {
|
|
||||||
fontSize: 14,
|
|
||||||
textAlign: 'center',
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default EmptyResults;
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Search Components
|
|
||||||
|
|
||||||
This directory contains modular components used in the SearchScreen.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
- **SearchBar**: Input field with search icon and clear button
|
|
||||||
- **SkeletonLoader**: Loading animation shown while searching
|
|
||||||
- **RecentSearches**: Shows recent search history
|
|
||||||
- **ResultsCarousel**: Horizontal scrolling list of search results by category
|
|
||||||
- **SearchResultItem**: Individual content card in the search results
|
|
||||||
- **EmptyResults**: Displayed when no search results are found
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import {
|
|
||||||
SearchBar,
|
|
||||||
SkeletonLoader,
|
|
||||||
RecentSearches,
|
|
||||||
ResultsCarousel,
|
|
||||||
EmptyResults
|
|
||||||
} from '../components/search';
|
|
||||||
|
|
||||||
// Use components in your screen...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Refactoring Benefits
|
|
||||||
|
|
||||||
- Improved code organization
|
|
||||||
- Smaller, reusable components
|
|
||||||
- Better separation of concerns
|
|
||||||
- Easier maintenance and testing
|
|
||||||
- Reduced file size of main screen component
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
|
||||||
import { colors } from '../../styles';
|
|
||||||
|
|
||||||
interface RecentSearchesProps {
|
|
||||||
searches: string[];
|
|
||||||
onSearchSelect: (search: string) => void;
|
|
||||||
isDarkMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RecentSearches: React.FC<RecentSearchesProps> = ({
|
|
||||||
searches,
|
|
||||||
onSearchSelect,
|
|
||||||
isDarkMode = true,
|
|
||||||
}) => {
|
|
||||||
if (searches.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.recentSearchesContainer}>
|
|
||||||
<Text style={[styles.carouselTitle, { color: isDarkMode ? colors.white : colors.black }]}>
|
|
||||||
Recent Searches
|
|
||||||
</Text>
|
|
||||||
{searches.map((search, index) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={index}
|
|
||||||
style={styles.recentSearchItem}
|
|
||||||
onPress={() => onSearchSelect(search)}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name="history"
|
|
||||||
size={20}
|
|
||||||
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
|
||||||
style={styles.recentSearchIcon}
|
|
||||||
/>
|
|
||||||
<Text style={[
|
|
||||||
styles.recentSearchText,
|
|
||||||
{ color: isDarkMode ? colors.white : colors.black }
|
|
||||||
]}>
|
|
||||||
{search}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
recentSearchesContainer: {
|
|
||||||
paddingHorizontal: 0,
|
|
||||||
paddingBottom: 16,
|
|
||||||
},
|
|
||||||
carouselTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.white,
|
|
||||||
marginBottom: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
},
|
|
||||||
recentSearchItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
},
|
|
||||||
recentSearchIcon: {
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
recentSearchText: {
|
|
||||||
fontSize: 16,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default RecentSearches;
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { View, Text, StyleSheet, FlatList } from 'react-native';
|
|
||||||
import { colors } from '../../styles';
|
|
||||||
import { StreamingContent } from '../../services/catalogService';
|
|
||||||
import SearchResultItem from './SearchResultItem';
|
|
||||||
|
|
||||||
interface ResultsCarouselProps {
|
|
||||||
title: string;
|
|
||||||
items: StreamingContent[];
|
|
||||||
onItemPress: (item: StreamingContent) => void;
|
|
||||||
isDarkMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResultsCarousel: React.FC<ResultsCarouselProps> = ({
|
|
||||||
title,
|
|
||||||
items,
|
|
||||||
onItemPress,
|
|
||||||
isDarkMode = true,
|
|
||||||
}) => {
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.carouselContainer}>
|
|
||||||
<Text style={styles.carouselTitle}>
|
|
||||||
{title} ({items.length})
|
|
||||||
</Text>
|
|
||||||
<FlatList
|
|
||||||
data={items}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<SearchResultItem
|
|
||||||
item={item}
|
|
||||||
onPress={onItemPress}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
keyExtractor={item => `${item.type}-${item.id}`}
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.horizontalListContent}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
carouselContainer: {
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
carouselTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.white,
|
|
||||||
marginBottom: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
},
|
|
||||||
horizontalListContent: {
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingRight: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ResultsCarousel;
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
|
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
|
||||||
import { colors } from '../../styles';
|
|
||||||
|
|
||||||
interface SearchBarProps {
|
|
||||||
query: string;
|
|
||||||
onChangeQuery: (text: string) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchBar: React.FC<SearchBarProps> = ({
|
|
||||||
query,
|
|
||||||
onChangeQuery,
|
|
||||||
onClear,
|
|
||||||
autoFocus = true
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View style={[
|
|
||||||
styles.searchBar,
|
|
||||||
{
|
|
||||||
backgroundColor: colors.darkGray,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
<MaterialIcons
|
|
||||||
name="search"
|
|
||||||
size={24}
|
|
||||||
color={colors.lightGray}
|
|
||||||
style={styles.searchIcon}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
styles.searchInput,
|
|
||||||
{ color: colors.white }
|
|
||||||
]}
|
|
||||||
placeholder="Search movies, shows..."
|
|
||||||
placeholderTextColor={colors.lightGray}
|
|
||||||
value={query}
|
|
||||||
onChangeText={onChangeQuery}
|
|
||||||
returnKeyType="search"
|
|
||||||
keyboardAppearance="dark"
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
/>
|
|
||||||
{query.length > 0 && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onClear}
|
|
||||||
style={styles.clearButton}
|
|
||||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name="close"
|
|
||||||
size={20}
|
|
||||||
color={colors.lightGray}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
searchBar: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: 24,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
height: 48,
|
|
||||||
},
|
|
||||||
searchIcon: {
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
searchInput: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 16,
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
clearButton: {
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SearchBar;
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
|
|
||||||
import { Image } from 'expo-image';
|
|
||||||
import { colors } from '../../styles';
|
|
||||||
import { StreamingContent } from '../../services/catalogService';
|
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
|
||||||
const HORIZONTAL_ITEM_WIDTH = width * 0.3;
|
|
||||||
const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5;
|
|
||||||
|
|
||||||
const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster';
|
|
||||||
|
|
||||||
interface SearchResultItemProps {
|
|
||||||
item: StreamingContent;
|
|
||||||
onPress: (item: StreamingContent) => void;
|
|
||||||
isDarkMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchResultItem: React.FC<SearchResultItemProps> = ({
|
|
||||||
item,
|
|
||||||
onPress,
|
|
||||||
isDarkMode = true
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.horizontalItem}
|
|
||||||
onPress={() => onPress(item)}
|
|
||||||
>
|
|
||||||
<View style={styles.horizontalItemPosterContainer}>
|
|
||||||
<Image
|
|
||||||
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
|
|
||||||
style={styles.horizontalItemPoster}
|
|
||||||
contentFit="cover"
|
|
||||||
transition={300}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.horizontalItemTitle,
|
|
||||||
{ color: isDarkMode ? colors.white : colors.black }
|
|
||||||
]}
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
horizontalItem: {
|
|
||||||
width: HORIZONTAL_ITEM_WIDTH,
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
horizontalItemPosterContainer: {
|
|
||||||
width: HORIZONTAL_ITEM_WIDTH,
|
|
||||||
height: HORIZONTAL_POSTER_HEIGHT,
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: colors.darkBackground,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
horizontalItemPoster: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
horizontalItemTitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
lineHeight: 18,
|
|
||||||
textAlign: 'left',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SearchResultItem;
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { View, StyleSheet, Animated } from 'react-native';
|
|
||||||
import { colors } from '../../styles';
|
|
||||||
|
|
||||||
const POSTER_WIDTH = 90;
|
|
||||||
const POSTER_HEIGHT = 135;
|
|
||||||
|
|
||||||
const SkeletonLoader: React.FC = () => {
|
|
||||||
const pulseAnim = React.useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const pulse = Animated.loop(
|
|
||||||
Animated.sequence([
|
|
||||||
Animated.timing(pulseAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 1000,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(pulseAnim, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 1000,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
pulse.start();
|
|
||||||
return () => pulse.stop();
|
|
||||||
}, [pulseAnim]);
|
|
||||||
|
|
||||||
const opacity = pulseAnim.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [0.3, 0.7],
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderSkeletonItem = () => (
|
|
||||||
<View style={styles.skeletonVerticalItem}>
|
|
||||||
<Animated.View style={[styles.skeletonPoster, { opacity }]} />
|
|
||||||
<View style={styles.skeletonItemDetails}>
|
|
||||||
<Animated.View style={[styles.skeletonTitle, { opacity }]} />
|
|
||||||
<View style={styles.skeletonMetaRow}>
|
|
||||||
<Animated.View style={[styles.skeletonMeta, { opacity }]} />
|
|
||||||
<Animated.View style={[styles.skeletonMeta, { opacity }]} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.skeletonContainer}>
|
|
||||||
{[...Array(5)].map((_, index) => (
|
|
||||||
<View key={index}>
|
|
||||||
{index === 0 && (
|
|
||||||
<Animated.View style={[styles.skeletonSectionHeader, { opacity }]} />
|
|
||||||
)}
|
|
||||||
{renderSkeletonItem()}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
skeletonContainer: {
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
skeletonVerticalItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
skeletonPoster: {
|
|
||||||
width: POSTER_WIDTH,
|
|
||||||
height: POSTER_HEIGHT,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: colors.darkBackground,
|
|
||||||
},
|
|
||||||
skeletonItemDetails: {
|
|
||||||
flex: 1,
|
|
||||||
marginLeft: 16,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
skeletonMetaRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 8,
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
skeletonTitle: {
|
|
||||||
height: 20,
|
|
||||||
width: '80%',
|
|
||||||
marginBottom: 8,
|
|
||||||
backgroundColor: colors.darkBackground,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
skeletonMeta: {
|
|
||||||
height: 14,
|
|
||||||
width: '30%',
|
|
||||||
backgroundColor: colors.darkBackground,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
skeletonSectionHeader: {
|
|
||||||
height: 24,
|
|
||||||
width: '40%',
|
|
||||||
backgroundColor: colors.darkBackground,
|
|
||||||
marginBottom: 16,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SkeletonLoader;
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export { default as SearchBar } from './SearchBar';
|
|
||||||
export { default as SkeletonLoader } from './SkeletonLoader';
|
|
||||||
export { default as RecentSearches } from './RecentSearches';
|
|
||||||
export { default as SearchResultItem } from './SearchResultItem';
|
|
||||||
export { default as ResultsCarousel } from './ResultsCarousel';
|
|
||||||
export { default as EmptyResults } from './EmptyResults';
|
|
||||||
|
|
@ -3,32 +3,95 @@ import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Keyboard,
|
TextInput,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
useColorScheme,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
ScrollView,
|
Keyboard,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
ScrollView,
|
||||||
|
Animated as RNAnimated,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import { colors } from '../styles';
|
import { colors } from '../styles';
|
||||||
import { catalogService, StreamingContent } from '../services/catalogService';
|
import { catalogService, StreamingContent } from '../services/catalogService';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import Animated, { FadeIn, FadeOut, SlideInRight } from 'react-native-reanimated';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import {
|
|
||||||
SearchBar,
|
|
||||||
SkeletonLoader,
|
|
||||||
RecentSearches,
|
|
||||||
ResultsCarousel,
|
|
||||||
EmptyResults
|
|
||||||
} from '../components/search';
|
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const HORIZONTAL_ITEM_WIDTH = width * 0.3;
|
||||||
|
const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5;
|
||||||
|
const POSTER_WIDTH = 90;
|
||||||
|
const POSTER_HEIGHT = 135;
|
||||||
const RECENT_SEARCHES_KEY = 'recent_searches';
|
const RECENT_SEARCHES_KEY = 'recent_searches';
|
||||||
const MAX_RECENT_SEARCHES = 10;
|
const MAX_RECENT_SEARCHES = 10;
|
||||||
|
|
||||||
const SearchScreen: React.FC = () => {
|
const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster';
|
||||||
|
|
||||||
|
const SkeletonLoader = () => {
|
||||||
|
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const pulse = RNAnimated.loop(
|
||||||
|
RNAnimated.sequence([
|
||||||
|
RNAnimated.timing(pulseAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
RNAnimated.timing(pulseAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
pulse.start();
|
||||||
|
return () => pulse.stop();
|
||||||
|
}, [pulseAnim]);
|
||||||
|
|
||||||
|
const opacity = pulseAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.3, 0.7],
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderSkeletonItem = () => (
|
||||||
|
<View style={styles.skeletonVerticalItem}>
|
||||||
|
<RNAnimated.View style={[styles.skeletonPoster, { opacity }]} />
|
||||||
|
<View style={styles.skeletonItemDetails}>
|
||||||
|
<RNAnimated.View style={[styles.skeletonTitle, { opacity }]} />
|
||||||
|
<View style={styles.skeletonMetaRow}>
|
||||||
|
<RNAnimated.View style={[styles.skeletonMeta, { opacity }]} />
|
||||||
|
<RNAnimated.View style={[styles.skeletonMeta, { opacity }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.skeletonContainer}>
|
||||||
|
{[...Array(5)].map((_, index) => (
|
||||||
|
<View key={index}>
|
||||||
|
{index === 0 && (
|
||||||
|
<RNAnimated.View style={[styles.skeletonSectionHeader, { opacity }]} />
|
||||||
|
)}
|
||||||
|
{renderSkeletonItem()}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SearchScreen = () => {
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const isDarkMode = true;
|
const isDarkMode = true;
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
|
@ -117,13 +180,65 @@ const SearchScreen: React.FC = () => {
|
||||||
loadRecentSearches();
|
loadRecentSearches();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRecentSearchSelect = (search: string) => {
|
const renderRecentSearches = () => {
|
||||||
setQuery(search);
|
if (!showRecent || recentSearches.length === 0) return null;
|
||||||
Keyboard.dismiss();
|
|
||||||
|
return (
|
||||||
|
<View style={styles.recentSearchesContainer}>
|
||||||
|
<Text style={[styles.carouselTitle, { color: isDarkMode ? colors.white : colors.black }]}>
|
||||||
|
Recent Searches
|
||||||
|
</Text>
|
||||||
|
{recentSearches.map((search, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={styles.recentSearchItem}
|
||||||
|
onPress={() => {
|
||||||
|
setQuery(search);
|
||||||
|
Keyboard.dismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="history"
|
||||||
|
size={20}
|
||||||
|
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||||
|
style={styles.recentSearchIcon}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.recentSearchText,
|
||||||
|
{ color: isDarkMode ? colors.white : colors.black }
|
||||||
|
]}>
|
||||||
|
{search}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleItemPress = (item: StreamingContent) => {
|
const renderHorizontalItem = ({ item }: { item: StreamingContent }) => {
|
||||||
navigation.navigate('Metadata', { id: item.id, type: item.type });
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.horizontalItem}
|
||||||
|
onPress={() => {
|
||||||
|
navigation.navigate('Metadata', { id: item.id, type: item.type });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.horizontalItemPosterContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
|
||||||
|
style={styles.horizontalItemPoster}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[styles.horizontalItemTitle, { color: isDarkMode ? colors.white : colors.black }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const movieResults = useMemo(() => {
|
const movieResults = useMemo(() => {
|
||||||
|
|
@ -150,17 +265,70 @@ const SearchScreen: React.FC = () => {
|
||||||
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>Search</Text>
|
<Text style={styles.headerTitle}>Search</Text>
|
||||||
<SearchBar
|
<View style={[
|
||||||
query={query}
|
styles.searchBar,
|
||||||
onChangeQuery={setQuery}
|
{
|
||||||
onClear={handleClearSearch}
|
backgroundColor: colors.darkGray,
|
||||||
/>
|
borderColor: 'transparent',
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="search"
|
||||||
|
size={24}
|
||||||
|
color={colors.lightGray}
|
||||||
|
style={styles.searchIcon}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.searchInput,
|
||||||
|
{ color: colors.white }
|
||||||
|
]}
|
||||||
|
placeholder="Search movies, shows..."
|
||||||
|
placeholderTextColor={colors.lightGray}
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
returnKeyType="search"
|
||||||
|
keyboardAppearance="dark"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{query.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleClearSearch}
|
||||||
|
style={styles.clearButton}
|
||||||
|
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="close"
|
||||||
|
size={20}
|
||||||
|
color={colors.lightGray}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<SkeletonLoader />
|
<SkeletonLoader />
|
||||||
) : searched && !hasResultsToShow ? (
|
) : searched && !hasResultsToShow ? (
|
||||||
<EmptyResults isDarkMode={isDarkMode} />
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="search-off"
|
||||||
|
size={64}
|
||||||
|
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: isDarkMode ? colors.white : colors.black }
|
||||||
|
]}>
|
||||||
|
No results found
|
||||||
|
</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.emptySubtext,
|
||||||
|
{ color: isDarkMode ? colors.lightGray : colors.mediumGray }
|
||||||
|
]}>
|
||||||
|
Try different keywords or check your spelling
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
|
|
@ -168,30 +336,34 @@ const SearchScreen: React.FC = () => {
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
onScrollBeginDrag={Keyboard.dismiss}
|
onScrollBeginDrag={Keyboard.dismiss}
|
||||||
>
|
>
|
||||||
{showRecent && (
|
{!query.trim() && renderRecentSearches()}
|
||||||
<RecentSearches
|
|
||||||
searches={recentSearches}
|
|
||||||
onSearchSelect={handleRecentSearchSelect}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{movieResults.length > 0 && (
|
{movieResults.length > 0 && (
|
||||||
<ResultsCarousel
|
<View style={styles.carouselContainer}>
|
||||||
title="Movies"
|
<Text style={styles.carouselTitle}>Movies ({movieResults.length})</Text>
|
||||||
items={movieResults}
|
<FlatList
|
||||||
onItemPress={handleItemPress}
|
data={movieResults}
|
||||||
isDarkMode={isDarkMode}
|
renderItem={renderHorizontalItem}
|
||||||
/>
|
keyExtractor={item => `movie-${item.id}`}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.horizontalListContent}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{seriesResults.length > 0 && (
|
{seriesResults.length > 0 && (
|
||||||
<ResultsCarousel
|
<View style={styles.carouselContainer}>
|
||||||
title="TV Shows"
|
<Text style={styles.carouselTitle}>TV Shows ({seriesResults.length})</Text>
|
||||||
items={seriesResults}
|
<FlatList
|
||||||
onItemPress={handleItemPress}
|
data={seriesResults}
|
||||||
isDarkMode={isDarkMode}
|
renderItem={renderHorizontalItem}
|
||||||
/>
|
keyExtractor={item => `series-${item.id}`}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.horizontalListContent}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
@ -217,12 +389,152 @@ const styles = StyleSheet.create({
|
||||||
color: colors.white,
|
color: colors.white,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
searchBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
searchIcon: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
scrollViewContent: {
|
scrollViewContent: {
|
||||||
paddingBottom: 20,
|
paddingBottom: 20,
|
||||||
},
|
},
|
||||||
|
carouselContainer: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
carouselTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.white,
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
horizontalListContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
horizontalItem: {
|
||||||
|
width: HORIZONTAL_ITEM_WIDTH,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
horizontalItemPosterContainer: {
|
||||||
|
width: HORIZONTAL_ITEM_WIDTH,
|
||||||
|
height: HORIZONTAL_POSTER_HEIGHT,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
horizontalItemPoster: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
horizontalItemTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: 18,
|
||||||
|
textAlign: 'left',
|
||||||
|
},
|
||||||
|
recentSearchesContainer: {
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
recentSearchItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
recentSearchIcon: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
recentSearchText: {
|
||||||
|
fontSize: 16,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
skeletonContainer: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
skeletonVerticalItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
skeletonPoster: {
|
||||||
|
width: POSTER_WIDTH,
|
||||||
|
height: POSTER_HEIGHT,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
skeletonItemDetails: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
skeletonMetaRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
skeletonTitle: {
|
||||||
|
height: 20,
|
||||||
|
width: '80%',
|
||||||
|
marginBottom: 8,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
skeletonMeta: {
|
||||||
|
height: 14,
|
||||||
|
width: '30%',
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
skeletonSectionHeader: {
|
||||||
|
height: 24,
|
||||||
|
width: '40%',
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default SearchScreen;
|
export default SearchScreen;
|
||||||
Loading…
Reference in a new issue