mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
discover screen optimization
This commit is contained in:
parent
cf5cc2d8f9
commit
ff2bca18a5
8 changed files with 738 additions and 196 deletions
112
src/components/search/RecentSearches.tsx
Normal file
112
src/components/search/RecentSearches.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Keyboard,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
||||
import { RECENT_SEARCHES_KEY, isTablet } from './searchUtils';
|
||||
|
||||
interface RecentSearchesProps {
|
||||
recentSearches: string[];
|
||||
onSearchPress: (query: string) => void;
|
||||
onSearchesChange: (searches: string[]) => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent search history list component
|
||||
*/
|
||||
export const RecentSearches: React.FC<RecentSearchesProps> = ({
|
||||
recentSearches,
|
||||
onSearchPress,
|
||||
onSearchesChange,
|
||||
visible,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
if (!visible || recentSearches.length === 0) return null;
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
const newRecentSearches = [...recentSearches];
|
||||
newRecentSearches.splice(index, 1);
|
||||
onSearchesChange(newRecentSearches);
|
||||
mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
|
||||
};
|
||||
|
||||
const handlePress = (search: string) => {
|
||||
onSearchPress(search);
|
||||
Keyboard.dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.white }]}>
|
||||
Recent Searches
|
||||
</Text>
|
||||
{recentSearches.map((search, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={styles.searchItem}
|
||||
onPress={() => handlePress(search)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="history"
|
||||
size={20}
|
||||
color={currentTheme.colors.lightGray}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text style={[styles.searchText, { color: currentTheme.colors.white }]}>
|
||||
{search}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDelete(index)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
style={styles.deleteButton}
|
||||
>
|
||||
<MaterialIcons name="close" size={16} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: isTablet ? 24 : 16,
|
||||
paddingTop: isTablet ? 12 : 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.05)',
|
||||
marginBottom: isTablet ? 16 : 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: isTablet ? 18 : 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
},
|
||||
searchItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: isTablet ? 12 : 10,
|
||||
paddingHorizontal: 16,
|
||||
marginVertical: 1,
|
||||
},
|
||||
icon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
searchText: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
deleteButton: {
|
||||
padding: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default RecentSearches;
|
||||
111
src/components/search/SearchAnimation.tsx
Normal file
111
src/components/search/SearchAnimation.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Animated as RNAnimated,
|
||||
Easing,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
/**
|
||||
* Animated search indicator shown while searching
|
||||
*/
|
||||
export const SearchAnimation: React.FC = () => {
|
||||
const spinAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||
const fadeAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Rotation animation
|
||||
const spin = RNAnimated.loop(
|
||||
RNAnimated.timing(spinAnim, {
|
||||
toValue: 1,
|
||||
duration: 1500,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Fade animation
|
||||
const fade = RNAnimated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
|
||||
// Start animations
|
||||
spin.start();
|
||||
fade.start();
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
spin.stop();
|
||||
};
|
||||
}, [spinAnim, fadeAnim]);
|
||||
|
||||
// Simple rotation interpolation
|
||||
const spin = spinAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
return (
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{ opacity: fadeAnim }
|
||||
]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<RNAnimated.View style={[
|
||||
styles.spinnerContainer,
|
||||
{ transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={32}
|
||||
color={currentTheme.colors.white}
|
||||
/>
|
||||
</RNAnimated.View>
|
||||
<Text style={[styles.text, { color: currentTheme.colors.white }]}>Searching</Text>
|
||||
</View>
|
||||
</RNAnimated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
},
|
||||
content: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
spinnerContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchAnimation;
|
||||
197
src/components/search/SearchResultItem.tsx
Normal file
197
src/components/search/SearchResultItem.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
DeviceEventEmitter,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons, Feather } from '@expo/vector-icons';
|
||||
import { StreamingContent, catalogService } from '../../services/catalogService';
|
||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import {
|
||||
HORIZONTAL_ITEM_WIDTH,
|
||||
HORIZONTAL_POSTER_HEIGHT,
|
||||
PLACEHOLDER_POSTER,
|
||||
isTablet,
|
||||
isLargeTablet,
|
||||
isTV,
|
||||
} from './searchUtils';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface SearchResultItemProps {
|
||||
item: StreamingContent;
|
||||
index: number;
|
||||
onPress: (item: StreamingContent) => void;
|
||||
onLongPress: (item: StreamingContent) => void;
|
||||
isGrid?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual search result item with poster, title, and badges
|
||||
*/
|
||||
export const SearchResultItem: React.FC<SearchResultItemProps> = React.memo(({
|
||||
item,
|
||||
index,
|
||||
onPress,
|
||||
onLongPress,
|
||||
isGrid = false,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const [inLibrary, setInLibrary] = useState(!!item.inLibrary);
|
||||
const [watched, setWatched] = 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) {
|
||||
// Ensure minimum 3 columns on all devices
|
||||
const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3;
|
||||
const minColumns = Math.max(3, columns);
|
||||
const totalPadding = 32;
|
||||
const totalGap = 12 * (minColumns - 1);
|
||||
const availableWidth = width - totalPadding - totalGap;
|
||||
w = availableWidth / minColumns;
|
||||
} 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]);
|
||||
|
||||
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]);
|
||||
|
||||
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]);
|
||||
|
||||
const borderRadius = settings.posterBorderRadius ?? 12;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.horizontalItem,
|
||||
{ width: itemWidth },
|
||||
isGrid && styles.discoverGridItem
|
||||
]}
|
||||
onPress={() => onPress(item)}
|
||||
onLongPress={() => onLongPress(item)}
|
||||
delayLongPress={300}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.horizontalItemPosterContainer, {
|
||||
width: itemWidth,
|
||||
height: undefined,
|
||||
aspectRatio: aspectRatio,
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
borderRadius,
|
||||
}]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.poster || PLACEHOLDER_POSTER,
|
||||
priority: FastImage.priority.low,
|
||||
cache: FastImage.cacheControl.immutable,
|
||||
}}
|
||||
style={[styles.horizontalItemPoster, { borderRadius }]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
</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 styles = StyleSheet.create({
|
||||
horizontalItem: {
|
||||
marginRight: 16,
|
||||
},
|
||||
discoverGridItem: {
|
||||
marginRight: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
horizontalItemPosterContainer: {
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
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: 14,
|
||||
fontWeight: '600',
|
||||
lineHeight: 18,
|
||||
textAlign: 'left',
|
||||
},
|
||||
yearText: {
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
libraryBadge: {},
|
||||
watchedIndicator: {},
|
||||
});
|
||||
|
||||
export default SearchResultItem;
|
||||
126
src/components/search/SearchSkeletonLoader.tsx
Normal file
126
src/components/search/SearchSkeletonLoader.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Animated as RNAnimated,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { isTablet } from './searchUtils';
|
||||
|
||||
/**
|
||||
* Skeleton loader component for search results
|
||||
*/
|
||||
export const SearchSkeletonLoader: React.FC = () => {
|
||||
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
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, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
<View style={styles.skeletonItemDetails}>
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonTitle,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
<View style={styles.skeletonMetaRow}>
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonMeta,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonMeta,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.skeletonContainer}>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<View key={index}>
|
||||
{index === 0 && (
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonSectionHeader,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
)}
|
||||
{renderSkeletonItem()}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
skeletonContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
},
|
||||
skeletonVerticalItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
skeletonPoster: {
|
||||
width: isTablet ? 60 : 80,
|
||||
height: isTablet ? 90 : 120,
|
||||
borderRadius: 8,
|
||||
marginRight: 12,
|
||||
},
|
||||
skeletonItemDetails: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
skeletonTitle: {
|
||||
height: 16,
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
width: '80%',
|
||||
},
|
||||
skeletonMetaRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
skeletonMeta: {
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
width: 60,
|
||||
},
|
||||
skeletonSectionHeader: {
|
||||
height: 20,
|
||||
width: 120,
|
||||
borderRadius: 4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchSkeletonLoader;
|
||||
6
src/components/search/index.ts
Normal file
6
src/components/search/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Search components barrel export
|
||||
export * from './searchUtils';
|
||||
export { SearchSkeletonLoader } from './SearchSkeletonLoader';
|
||||
export { SearchAnimation } from './SearchAnimation';
|
||||
export { SearchResultItem } from './SearchResultItem';
|
||||
export { RecentSearches } from './RecentSearches';
|
||||
46
src/components/search/searchUtils.ts
Normal file
46
src/components/search/searchUtils.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Dimensions } from 'react-native';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// Catalog info type for discover
|
||||
export interface DiscoverCatalog {
|
||||
addonId: string;
|
||||
addonName: string;
|
||||
catalogId: string;
|
||||
catalogName: string;
|
||||
type: string;
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
// Enhanced responsive breakpoints
|
||||
export const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
} as const;
|
||||
|
||||
export const getDeviceType = (deviceWidth: number) => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
|
||||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
};
|
||||
|
||||
// Current device calculations
|
||||
export const deviceType = getDeviceType(width);
|
||||
export const isTablet = deviceType === 'tablet';
|
||||
export const isLargeTablet = deviceType === 'largeTablet';
|
||||
export const isTV = deviceType === 'tv';
|
||||
|
||||
// Constants
|
||||
export const TAB_BAR_HEIGHT = 85;
|
||||
export const RECENT_SEARCHES_KEY = 'recent_searches';
|
||||
export const MAX_RECENT_SEARCHES = 10;
|
||||
export const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster';
|
||||
|
||||
// Responsive poster sizes
|
||||
export const HORIZONTAL_ITEM_WIDTH = isTV ? width * 0.14 : isLargeTablet ? width * 0.16 : isTablet ? width * 0.18 : width * 0.3;
|
||||
export const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5;
|
||||
export const POSTER_WIDTH = isTV ? 90 : isLargeTablet ? 80 : isTablet ? 70 : 90;
|
||||
export const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
|
||||
|
|
@ -49,188 +49,41 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
|||
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
|
||||
// Catalog info type for discover
|
||||
interface DiscoverCatalog {
|
||||
addonId: string;
|
||||
addonName: string;
|
||||
catalogId: string;
|
||||
catalogName: string;
|
||||
type: string;
|
||||
genres: string[];
|
||||
}
|
||||
// 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');
|
||||
|
||||
// Enhanced responsive breakpoints
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
};
|
||||
|
||||
const getDeviceType = (deviceWidth: number) => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
|
||||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
};
|
||||
|
||||
// Re-export for local use (backward compatibility)
|
||||
const deviceType = getDeviceType(width);
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const TAB_BAR_HEIGHT = 85;
|
||||
|
||||
// Responsive poster sizes
|
||||
const HORIZONTAL_ITEM_WIDTH = isTV ? width * 0.14 : isLargeTablet ? width * 0.16 : isTablet ? width * 0.18 : width * 0.3;
|
||||
const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5;
|
||||
const POSTER_WIDTH = isTV ? 90 : isLargeTablet ? 80 : isTablet ? 70 : 90;
|
||||
const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
|
||||
const RECENT_SEARCHES_KEY = 'recent_searches';
|
||||
const MAX_RECENT_SEARCHES = 10;
|
||||
|
||||
const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster';
|
||||
|
||||
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
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, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
<View style={styles.skeletonItemDetails}>
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonTitle,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
<View style={styles.skeletonMetaRow}>
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonMeta,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonMeta,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.skeletonContainer}>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<View key={index}>
|
||||
{index === 0 && (
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonSectionHeader,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
)}
|
||||
{renderSkeletonItem()}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
// Alias imported components for backward compatibility with existing code
|
||||
const SkeletonLoader = SearchSkeletonLoader;
|
||||
const SimpleSearchAnimation = SearchAnimation;
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
// Create a simple, elegant animation component
|
||||
const SimpleSearchAnimation = () => {
|
||||
// Simple animation values that work reliably
|
||||
const spinAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||
const fadeAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Rotation animation
|
||||
const spin = RNAnimated.loop(
|
||||
RNAnimated.timing(spinAnim, {
|
||||
toValue: 1,
|
||||
duration: 1500,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Fade animation
|
||||
const fade = RNAnimated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
|
||||
// Start animations
|
||||
spin.start();
|
||||
fade.start();
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
spin.stop();
|
||||
};
|
||||
}, [spinAnim, fadeAnim]);
|
||||
|
||||
// Simple rotation interpolation
|
||||
const spin = spinAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
return (
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
styles.simpleAnimationContainer,
|
||||
{ opacity: fadeAnim }
|
||||
]}
|
||||
>
|
||||
<View style={styles.simpleAnimationContent}>
|
||||
<RNAnimated.View style={[
|
||||
styles.spinnerContainer,
|
||||
{ transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={32}
|
||||
color={currentTheme.colors.white}
|
||||
/>
|
||||
</RNAnimated.View>
|
||||
<Text style={[styles.simpleAnimationText, { color: currentTheme.colors.white }]}>Searching</Text>
|
||||
</View>
|
||||
</RNAnimated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchScreen = () => {
|
||||
const { settings } = useSettings();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
|
@ -264,6 +117,7 @@ const SearchScreen = () => {
|
|||
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);
|
||||
|
|
@ -290,6 +144,18 @@ const SearchScreen = () => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
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 () => {
|
||||
|
|
@ -335,6 +201,7 @@ const SearchScreen = () => {
|
|||
setDiscoverLoading(true);
|
||||
setPage(1); // Reset page on new filter
|
||||
setHasMore(true);
|
||||
setPendingDiscoverResults([]);
|
||||
try {
|
||||
const results = await catalogService.discoverContentFromCatalog(
|
||||
selectedCatalog.addonId,
|
||||
|
|
@ -344,8 +211,15 @@ const SearchScreen = () => {
|
|||
1 // page 1
|
||||
);
|
||||
if (isMounted.current) {
|
||||
setDiscoverResults(results);
|
||||
setHasMore(results.length > 0);
|
||||
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);
|
||||
|
|
@ -364,7 +238,7 @@ const SearchScreen = () => {
|
|||
|
||||
// Load more content for pagination
|
||||
const loadMoreDiscoverContent = async () => {
|
||||
if (!hasMore || loadingMore || discoverLoading || !selectedCatalog) return;
|
||||
if (!hasMore || loadingMore || discoverLoading || !selectedCatalog || pendingDiscoverResults.length > 0) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
const nextPage = page + 1;
|
||||
|
|
@ -380,7 +254,12 @@ const SearchScreen = () => {
|
|||
|
||||
if (isMounted.current) {
|
||||
if (moreResults.length > 0) {
|
||||
setDiscoverResults(prev => [...prev, ...moreResults]);
|
||||
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);
|
||||
|
|
@ -893,8 +772,14 @@ const SearchScreen = () => {
|
|||
</Text>
|
||||
</View>
|
||||
) : discoverResults.length > 0 ? (
|
||||
<View style={styles.discoverGrid}>
|
||||
{discoverResults.map((item, index) => (
|
||||
<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}
|
||||
|
|
@ -905,13 +790,31 @@ const SearchScreen = () => {
|
|||
currentTheme={currentTheme}
|
||||
isGrid={true}
|
||||
/>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<View style={styles.loadingMoreContainer}>
|
||||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
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} />
|
||||
|
|
@ -961,11 +864,12 @@ const SearchScreen = () => {
|
|||
// 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 * (columns - 1);
|
||||
const totalGap = 12 * (Math.max(3, columns) - 1);
|
||||
const availableWidth = width - totalPadding - totalGap;
|
||||
w = availableWidth / columns;
|
||||
w = availableWidth / Math.max(3, columns);
|
||||
} else {
|
||||
if (shape === 'landscape') {
|
||||
r = 16 / 9;
|
||||
|
|
@ -1020,7 +924,11 @@ const SearchScreen = () => {
|
|||
borderRadius: settings.posterBorderRadius ?? 12,
|
||||
}]}>
|
||||
<FastImage
|
||||
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
|
||||
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}
|
||||
/>
|
||||
|
|
@ -1342,7 +1250,7 @@ const SearchScreen = () => {
|
|||
scrollEventThrottle={16}
|
||||
onScroll={({ nativeEvent }) => {
|
||||
// Only paginate if query is empty (Discover mode)
|
||||
if (query.trim().length > 0 || !settings.showDiscover) return;
|
||||
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;
|
||||
|
|
@ -1418,6 +1326,8 @@ const SearchScreen = () => {
|
|||
enableDynamicSizing={false}
|
||||
enablePanDownToClose={true}
|
||||
backdropComponent={renderBackdrop}
|
||||
android_keyboardInputMode="adjustResize"
|
||||
animateOnMount={true}
|
||||
backgroundStyle={{
|
||||
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
||||
borderTopLeftRadius: 16,
|
||||
|
|
@ -1476,6 +1386,8 @@ const SearchScreen = () => {
|
|||
enableDynamicSizing={false}
|
||||
enablePanDownToClose={true}
|
||||
backdropComponent={renderBackdrop}
|
||||
android_keyboardInputMode="adjustResize"
|
||||
animateOnMount={true}
|
||||
backgroundStyle={{
|
||||
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
||||
borderTopLeftRadius: 16,
|
||||
|
|
@ -2030,9 +1942,17 @@ const styles = StyleSheet.create({
|
|||
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: 0, // Gap handles this now
|
||||
marginBottom: 12,
|
||||
},
|
||||
loadingMoreContainer: {
|
||||
width: '100%',
|
||||
|
|
@ -2119,6 +2039,22 @@ const styles = StyleSheet.create({
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -1142,18 +1142,16 @@ class CatalogService {
|
|||
const supportsGenre = catalog.extra?.some(e => e.name === 'genre') ||
|
||||
catalog.extraSupported?.includes('genre');
|
||||
|
||||
// If genre is specified, only use catalogs that support genre OR have no filter restrictions
|
||||
// If genre is specified but catalog doesn't support genre filter, skip it
|
||||
if (genre && !supportsGenre) {
|
||||
continue;
|
||||
}
|
||||
// If genre is specified but not supported, we still fetch but without the filter
|
||||
// This ensures we don't skip addons that don't support the filter
|
||||
|
||||
const manifest = manifests.find(m => m.id === addon.id);
|
||||
if (!manifest) continue;
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
const filters = genre ? [{ title: 'genre', value: genre }] : [];
|
||||
// Only apply genre filter if supported
|
||||
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
|
||||
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
|
||||
|
||||
if (metas && metas.length > 0) {
|
||||
|
|
@ -1220,7 +1218,17 @@ class CatalogService {
|
|||
return [];
|
||||
}
|
||||
|
||||
const filters = genre ? [{ title: 'genre', value: genre }] : [];
|
||||
// Find the catalog to check if it supports genre filter
|
||||
const addon = (await this.getAllAddons()).find(a => a.id === addonId);
|
||||
const catalog = addon?.catalogs?.find(c => c.id === catalogId);
|
||||
|
||||
// Check if catalog supports genre filter
|
||||
const supportsGenre = catalog?.extra?.some((e: any) => e.name === 'genre') ||
|
||||
catalog?.extraSupported?.includes('genre');
|
||||
|
||||
// Only apply genre filter if the catalog supports it
|
||||
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
|
||||
|
||||
const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
|
||||
|
||||
if (metas && metas.length > 0) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue