mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-05 01:39:08 +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 { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
|
||||||
import { useSettings } from '../hooks/useSettings';
|
import { useSettings } from '../hooks/useSettings';
|
||||||
|
|
||||||
// Catalog info type for discover
|
// Import extracted search components
|
||||||
interface DiscoverCatalog {
|
import {
|
||||||
addonId: string;
|
DiscoverCatalog,
|
||||||
addonName: string;
|
BREAKPOINTS,
|
||||||
catalogId: string;
|
getDeviceType,
|
||||||
catalogName: string;
|
isTablet,
|
||||||
type: string;
|
isLargeTablet,
|
||||||
genres: string[];
|
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');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
// Enhanced responsive breakpoints
|
// Re-export for local use (backward compatibility)
|
||||||
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';
|
|
||||||
};
|
|
||||||
|
|
||||||
const deviceType = getDeviceType(width);
|
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 AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
||||||
|
|
||||||
const SkeletonLoader = () => {
|
// Alias imported components for backward compatibility with existing code
|
||||||
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current;
|
const SkeletonLoader = SearchSkeletonLoader;
|
||||||
const { currentTheme } = useTheme();
|
const SimpleSearchAnimation = SearchAnimation;
|
||||||
|
|
||||||
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 ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
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 SearchScreen = () => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
|
@ -264,6 +117,7 @@ const SearchScreen = () => {
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [discoverResults, setDiscoverResults] = useState<StreamingContent[]>([]);
|
const [discoverResults, setDiscoverResults] = useState<StreamingContent[]>([]);
|
||||||
|
const [pendingDiscoverResults, setPendingDiscoverResults] = useState<StreamingContent[]>([]);
|
||||||
|
|
||||||
const [discoverLoading, setDiscoverLoading] = useState(false);
|
const [discoverLoading, setDiscoverLoading] = useState(false);
|
||||||
const [discoverInitialized, setDiscoverInitialized] = 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
|
// Load discover catalogs on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDiscoverCatalogs = async () => {
|
const loadDiscoverCatalogs = async () => {
|
||||||
|
|
@ -335,6 +201,7 @@ const SearchScreen = () => {
|
||||||
setDiscoverLoading(true);
|
setDiscoverLoading(true);
|
||||||
setPage(1); // Reset page on new filter
|
setPage(1); // Reset page on new filter
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
|
setPendingDiscoverResults([]);
|
||||||
try {
|
try {
|
||||||
const results = await catalogService.discoverContentFromCatalog(
|
const results = await catalogService.discoverContentFromCatalog(
|
||||||
selectedCatalog.addonId,
|
selectedCatalog.addonId,
|
||||||
|
|
@ -344,8 +211,15 @@ const SearchScreen = () => {
|
||||||
1 // page 1
|
1 // page 1
|
||||||
);
|
);
|
||||||
if (isMounted.current) {
|
if (isMounted.current) {
|
||||||
setDiscoverResults(results);
|
if (results.length > 300) {
|
||||||
setHasMore(results.length > 0);
|
setDiscoverResults(results.slice(0, 300));
|
||||||
|
setPendingDiscoverResults(results.slice(300));
|
||||||
|
setHasMore(true);
|
||||||
|
} else {
|
||||||
|
setDiscoverResults(results);
|
||||||
|
setPendingDiscoverResults([]);
|
||||||
|
setHasMore(results.length > 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch discover content:', error);
|
logger.error('Failed to fetch discover content:', error);
|
||||||
|
|
@ -364,7 +238,7 @@ const SearchScreen = () => {
|
||||||
|
|
||||||
// Load more content for pagination
|
// Load more content for pagination
|
||||||
const loadMoreDiscoverContent = async () => {
|
const loadMoreDiscoverContent = async () => {
|
||||||
if (!hasMore || loadingMore || discoverLoading || !selectedCatalog) return;
|
if (!hasMore || loadingMore || discoverLoading || !selectedCatalog || pendingDiscoverResults.length > 0) return;
|
||||||
|
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
const nextPage = page + 1;
|
const nextPage = page + 1;
|
||||||
|
|
@ -380,7 +254,12 @@ const SearchScreen = () => {
|
||||||
|
|
||||||
if (isMounted.current) {
|
if (isMounted.current) {
|
||||||
if (moreResults.length > 0) {
|
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);
|
setPage(nextPage);
|
||||||
} else {
|
} else {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
|
|
@ -893,8 +772,14 @@ const SearchScreen = () => {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : discoverResults.length > 0 ? (
|
) : discoverResults.length > 0 ? (
|
||||||
<View style={styles.discoverGrid}>
|
<FlatList
|
||||||
{discoverResults.map((item, index) => (
|
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
|
<SearchResultItem
|
||||||
key={`discover-${item.id}-${index}`}
|
key={`discover-${item.id}-${index}`}
|
||||||
item={item}
|
item={item}
|
||||||
|
|
@ -905,13 +790,31 @@ const SearchScreen = () => {
|
||||||
currentTheme={currentTheme}
|
currentTheme={currentTheme}
|
||||||
isGrid={true}
|
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 ? (
|
) : discoverInitialized && !discoverLoading && selectedCatalog ? (
|
||||||
<View style={styles.discoverEmptyContainer}>
|
<View style={styles.discoverEmptyContainer}>
|
||||||
<MaterialIcons name="movie-filter" size={48} color={currentTheme.colors.lightGray} />
|
<MaterialIcons name="movie-filter" size={48} color={currentTheme.colors.lightGray} />
|
||||||
|
|
@ -961,11 +864,12 @@ const SearchScreen = () => {
|
||||||
// Grid Calculation: (Window Width - Padding) / Columns
|
// Grid Calculation: (Window Width - Padding) / Columns
|
||||||
// Padding: 16 (left) + 16 (right) = 32
|
// Padding: 16 (left) + 16 (right) = 32
|
||||||
// Gap: 12 (between items) * (columns - 1)
|
// Gap: 12 (between items) * (columns - 1)
|
||||||
|
// Ensure minimum 3 columns on all devices
|
||||||
const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3;
|
const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3;
|
||||||
const totalPadding = 32;
|
const totalPadding = 32;
|
||||||
const totalGap = 12 * (columns - 1);
|
const totalGap = 12 * (Math.max(3, columns) - 1);
|
||||||
const availableWidth = width - totalPadding - totalGap;
|
const availableWidth = width - totalPadding - totalGap;
|
||||||
w = availableWidth / columns;
|
w = availableWidth / Math.max(3, columns);
|
||||||
} else {
|
} else {
|
||||||
if (shape === 'landscape') {
|
if (shape === 'landscape') {
|
||||||
r = 16 / 9;
|
r = 16 / 9;
|
||||||
|
|
@ -1020,7 +924,11 @@ const SearchScreen = () => {
|
||||||
borderRadius: settings.posterBorderRadius ?? 12,
|
borderRadius: settings.posterBorderRadius ?? 12,
|
||||||
}]}>
|
}]}>
|
||||||
<FastImage
|
<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 }]}
|
style={[styles.horizontalItemPoster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
|
||||||
resizeMode={FastImage.resizeMode.cover}
|
resizeMode={FastImage.resizeMode.cover}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1342,7 +1250,7 @@ const SearchScreen = () => {
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onScroll={({ nativeEvent }) => {
|
onScroll={({ nativeEvent }) => {
|
||||||
// Only paginate if query is empty (Discover mode)
|
// 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 { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||||
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 500;
|
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 500;
|
||||||
|
|
@ -1418,6 +1326,8 @@ const SearchScreen = () => {
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
enablePanDownToClose={true}
|
enablePanDownToClose={true}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
|
android_keyboardInputMode="adjustResize"
|
||||||
|
animateOnMount={true}
|
||||||
backgroundStyle={{
|
backgroundStyle={{
|
||||||
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
|
|
@ -1476,6 +1386,8 @@ const SearchScreen = () => {
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
enablePanDownToClose={true}
|
enablePanDownToClose={true}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
|
android_keyboardInputMode="adjustResize"
|
||||||
|
animateOnMount={true}
|
||||||
backgroundStyle={{
|
backgroundStyle={{
|
||||||
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
|
|
@ -2030,9 +1942,17 @@ const styles = StyleSheet.create({
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
gap: 12, // vertical and horizontal gap
|
gap: 12, // vertical and horizontal gap
|
||||||
},
|
},
|
||||||
|
discoverGridRow: {
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
discoverGridContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
discoverGridItem: {
|
discoverGridItem: {
|
||||||
marginRight: 0, // Override horizontalItem margin
|
marginRight: 0, // Override horizontalItem margin
|
||||||
marginBottom: 0, // Gap handles this now
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
loadingMoreContainer: {
|
loadingMoreContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -2119,6 +2039,22 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
marginTop: 2,
|
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;
|
export default SearchScreen;
|
||||||
|
|
|
||||||
|
|
@ -1142,18 +1142,16 @@ class CatalogService {
|
||||||
const supportsGenre = catalog.extra?.some(e => e.name === 'genre') ||
|
const supportsGenre = catalog.extra?.some(e => e.name === 'genre') ||
|
||||||
catalog.extraSupported?.includes('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 not supported, we still fetch but without the filter
|
||||||
// If genre is specified but catalog doesn't support genre filter, skip it
|
// This ensures we don't skip addons that don't support the filter
|
||||||
if (genre && !supportsGenre) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = manifests.find(m => m.id === addon.id);
|
const manifest = manifests.find(m => m.id === addon.id);
|
||||||
if (!manifest) continue;
|
if (!manifest) continue;
|
||||||
|
|
||||||
const fetchPromise = (async () => {
|
const fetchPromise = (async () => {
|
||||||
try {
|
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);
|
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
|
||||||
|
|
||||||
if (metas && metas.length > 0) {
|
if (metas && metas.length > 0) {
|
||||||
|
|
@ -1220,7 +1218,17 @@ class CatalogService {
|
||||||
return [];
|
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);
|
const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
|
||||||
|
|
||||||
if (metas && metas.length > 0) {
|
if (metas && metas.length > 0) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue