mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Refactor DiscoverScreen component by removing unused imports and optimizing structure. Introduce CategorySelector and GenreSelector components for better organization. Update loading state handling and improve empty state rendering.
This commit is contained in:
parent
dbaadbe61b
commit
cf03a44fab
8 changed files with 609 additions and 542 deletions
132
src/components/discover/CatalogSection.tsx
Normal file
132
src/components/discover/CatalogSection.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, FlatList, Dimensions } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { colors } from '../../styles';
|
||||
import { GenreCatalog, Category } from '../../constants/discover';
|
||||
import { StreamingContent } from '../../services/catalogService';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import ContentItem from './ContentItem';
|
||||
|
||||
interface CatalogSectionProps {
|
||||
catalog: GenreCatalog;
|
||||
selectedCategory: Category;
|
||||
}
|
||||
|
||||
const CatalogSection = ({ catalog, selectedCategory }: CatalogSectionProps) => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { width } = Dimensions.get('window');
|
||||
const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing
|
||||
|
||||
// Only display first 3 items in each section
|
||||
const displayItems = useMemo(() =>
|
||||
catalog.items.slice(0, 3),
|
||||
[catalog.items]
|
||||
);
|
||||
|
||||
const handleContentPress = useCallback((item: StreamingContent) => {
|
||||
navigation.navigate('Metadata', { id: item.id, type: item.type });
|
||||
}, [navigation]);
|
||||
|
||||
const handleSeeMorePress = useCallback(() => {
|
||||
navigation.navigate('Catalog', {
|
||||
id: catalog.genre,
|
||||
type: selectedCategory.type,
|
||||
name: `${catalog.genre} ${selectedCategory.name}`,
|
||||
genreFilter: catalog.genre
|
||||
});
|
||||
}, [navigation, selectedCategory, catalog.genre]);
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: StreamingContent }) => (
|
||||
<ContentItem
|
||||
item={item}
|
||||
onPress={() => handleContentPress(item)}
|
||||
width={itemWidth}
|
||||
/>
|
||||
), [handleContentPress, itemWidth]);
|
||||
|
||||
const keyExtractor = useCallback((item: StreamingContent) => item.id, []);
|
||||
|
||||
const ItemSeparator = useCallback(() => (
|
||||
<View style={{ width: 16 }} />
|
||||
), []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title}>{catalog.genre}</Text>
|
||||
<View style={styles.titleBar} />
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleSeeMorePress}
|
||||
style={styles.seeAllButton}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<Text style={styles.seeAllText}>See All</Text>
|
||||
<MaterialIcons name="arrow-forward-ios" color={colors.primary} size={14} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={displayItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
snapToInterval={itemWidth + 16}
|
||||
decelerationRate="fast"
|
||||
snapToAlignment="start"
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={3}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
titleBar: {
|
||||
width: 32,
|
||||
height: 3,
|
||||
backgroundColor: colors.primary,
|
||||
marginTop: 6,
|
||||
borderRadius: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
},
|
||||
seeAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
seeAllText: {
|
||||
color: colors.primary,
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(CatalogSection);
|
||||
43
src/components/discover/CatalogsList.tsx
Normal file
43
src/components/discover/CatalogsList.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { FlatList, StyleSheet, Platform } from 'react-native';
|
||||
import { GenreCatalog, Category } from '../../constants/discover';
|
||||
import CatalogSection from './CatalogSection';
|
||||
|
||||
interface CatalogsListProps {
|
||||
catalogs: GenreCatalog[];
|
||||
selectedCategory: Category;
|
||||
}
|
||||
|
||||
const CatalogsList = ({ catalogs, selectedCategory }: CatalogsListProps) => {
|
||||
const renderCatalogItem = useCallback(({ item }: { item: GenreCatalog }) => (
|
||||
<CatalogSection
|
||||
catalog={item}
|
||||
selectedCategory={selectedCategory}
|
||||
/>
|
||||
), [selectedCategory]);
|
||||
|
||||
// Memoize list key extractor
|
||||
const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={catalogs}
|
||||
renderItem={renderCatalogItem}
|
||||
keyExtractor={catalogKeyExtractor}
|
||||
contentContainerStyle={styles.container}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(CatalogsList);
|
||||
101
src/components/discover/CategorySelector.tsx
Normal file
101
src/components/discover/CategorySelector.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { colors } from '../../styles';
|
||||
import { Category } from '../../constants/discover';
|
||||
|
||||
interface CategorySelectorProps {
|
||||
categories: Category[];
|
||||
selectedCategory: Category;
|
||||
onSelectCategory: (category: Category) => void;
|
||||
}
|
||||
|
||||
const CategorySelector = ({
|
||||
categories,
|
||||
selectedCategory,
|
||||
onSelectCategory
|
||||
}: CategorySelectorProps) => {
|
||||
|
||||
const renderCategoryButton = useCallback((category: Category) => {
|
||||
const isSelected = selectedCategory.id === category.id;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.categoryButton,
|
||||
isSelected && styles.selectedCategoryButton
|
||||
]}
|
||||
onPress={() => onSelectCategory(category)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={category.icon}
|
||||
size={24}
|
||||
color={isSelected ? colors.white : colors.mediumGray}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.categoryText,
|
||||
isSelected && styles.selectedCategoryText
|
||||
]}
|
||||
>
|
||||
{category.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, [selectedCategory, onSelectCategory]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
{categories.map(renderCategoryButton)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingVertical: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
gap: 16,
|
||||
},
|
||||
categoryButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
flex: 1,
|
||||
maxWidth: 160,
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
selectedCategoryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
categoryText: {
|
||||
color: colors.mediumGray,
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
},
|
||||
selectedCategoryText: {
|
||||
color: colors.white,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(CategorySelector);
|
||||
94
src/components/discover/ContentItem.tsx
Normal file
94
src/components/discover/ContentItem.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Dimensions } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors } from '../../styles';
|
||||
import { StreamingContent } from '../../services/catalogService';
|
||||
|
||||
interface ContentItemProps {
|
||||
item: StreamingContent;
|
||||
onPress: () => void;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
const ContentItem = ({ item, onPress, width }: ContentItemProps) => {
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const itemWidth = width || (screenWidth - 48) / 2.2; // Default to 2 items per row with spacing
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { width: itemWidth }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<View style={styles.posterContainer}>
|
||||
<Image
|
||||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||
style={styles.poster}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
transition={300}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.85)']}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.year && (
|
||||
<Text style={styles.year}>{item.year}</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginHorizontal: 0,
|
||||
},
|
||||
posterContainer: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
elevation: 5,
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
poster: {
|
||||
aspectRatio: 2/3,
|
||||
width: '100%',
|
||||
},
|
||||
gradient: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
justifyContent: 'flex-end',
|
||||
height: '45%',
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
marginBottom: 4,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
year: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(ContentItem);
|
||||
94
src/components/discover/GenreSelector.tsx
Normal file
94
src/components/discover/GenreSelector.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native';
|
||||
import { colors } from '../../styles';
|
||||
|
||||
interface GenreSelectorProps {
|
||||
genres: string[];
|
||||
selectedGenre: string;
|
||||
onSelectGenre: (genre: string) => void;
|
||||
}
|
||||
|
||||
const GenreSelector = ({
|
||||
genres,
|
||||
selectedGenre,
|
||||
onSelectGenre
|
||||
}: GenreSelectorProps) => {
|
||||
|
||||
const renderGenreButton = useCallback((genre: string) => {
|
||||
const isSelected = selectedGenre === genre;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={genre}
|
||||
style={[
|
||||
styles.genreButton,
|
||||
isSelected && styles.selectedGenreButton
|
||||
]}
|
||||
onPress={() => onSelectGenre(genre)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.genreText,
|
||||
isSelected && styles.selectedGenreText
|
||||
]}
|
||||
>
|
||||
{genre}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, [selectedGenre, onSelectGenre]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollViewContent}
|
||||
decelerationRate="fast"
|
||||
snapToInterval={10}
|
||||
>
|
||||
{genres.map(renderGenreButton)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingTop: 20,
|
||||
paddingBottom: 12,
|
||||
zIndex: 10,
|
||||
},
|
||||
scrollViewContent: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
genreButton: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 10,
|
||||
marginRight: 12,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
selectedGenreButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
genreText: {
|
||||
color: colors.mediumGray,
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
},
|
||||
selectedGenreText: {
|
||||
color: colors.white,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(GenreSelector);
|
||||
42
src/constants/discover.ts
Normal file
42
src/constants/discover.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { StreamingContent } from '../services/catalogService';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'movie' | 'series' | 'channel' | 'tv';
|
||||
icon: keyof typeof MaterialIcons.glyphMap;
|
||||
}
|
||||
|
||||
export interface GenreCatalog {
|
||||
genre: string;
|
||||
items: StreamingContent[];
|
||||
}
|
||||
|
||||
export const CATEGORIES: Category[] = [
|
||||
{ id: 'movie', name: 'Movies', type: 'movie', icon: 'local-movies' },
|
||||
{ id: 'series', name: 'TV Shows', type: 'series', icon: 'live-tv' }
|
||||
];
|
||||
|
||||
// Common genres for movies and TV shows
|
||||
export const COMMON_GENRES = [
|
||||
'All',
|
||||
'Action',
|
||||
'Adventure',
|
||||
'Animation',
|
||||
'Comedy',
|
||||
'Crime',
|
||||
'Documentary',
|
||||
'Drama',
|
||||
'Family',
|
||||
'Fantasy',
|
||||
'History',
|
||||
'Horror',
|
||||
'Music',
|
||||
'Mystery',
|
||||
'Romance',
|
||||
'Science Fiction',
|
||||
'Thriller',
|
||||
'War',
|
||||
'Western'
|
||||
];
|
||||
|
|
@ -1,507 +1,41 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
SafeAreaView,
|
||||
StatusBar,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
Platform,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { colors } from '../styles';
|
||||
import { catalogService, StreamingContent, CatalogContent } from '../services/catalogService';
|
||||
import { Image } from 'expo-image';
|
||||
import { FadeIn, FadeOut, SlideInRight, Layout } from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { catalogService, StreamingContent } from '../services/catalogService';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { logger } from '../utils/logger';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'movie' | 'series' | 'channel' | 'tv';
|
||||
icon: keyof typeof MaterialIcons.glyphMap;
|
||||
}
|
||||
// Components
|
||||
import CategorySelector from '../components/discover/CategorySelector';
|
||||
import GenreSelector from '../components/discover/GenreSelector';
|
||||
import CatalogsList from '../components/discover/CatalogsList';
|
||||
|
||||
interface GenreCatalog {
|
||||
genre: string;
|
||||
items: StreamingContent[];
|
||||
}
|
||||
// Constants and types
|
||||
import { CATEGORIES, COMMON_GENRES, Category, GenreCatalog } from '../constants/discover';
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
{ id: 'movie', name: 'Movies', type: 'movie', icon: 'local-movies' },
|
||||
{ id: 'series', name: 'TV Shows', type: 'series', icon: 'live-tv' }
|
||||
];
|
||||
|
||||
// Common genres for movies and TV shows
|
||||
const COMMON_GENRES = [
|
||||
'All',
|
||||
'Action',
|
||||
'Adventure',
|
||||
'Animation',
|
||||
'Comedy',
|
||||
'Crime',
|
||||
'Documentary',
|
||||
'Drama',
|
||||
'Family',
|
||||
'Fantasy',
|
||||
'History',
|
||||
'Horror',
|
||||
'Music',
|
||||
'Mystery',
|
||||
'Romance',
|
||||
'Science Fiction',
|
||||
'Thriller',
|
||||
'War',
|
||||
'Western'
|
||||
];
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
// Memoized child components
|
||||
const CategoryButton = React.memo(({
|
||||
category,
|
||||
isSelected,
|
||||
onPress
|
||||
}: {
|
||||
category: Category;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.categoryButton,
|
||||
isSelected && styles.selectedCategoryButton
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={category.icon}
|
||||
size={24}
|
||||
color={isSelected ? colors.white : colors.mediumGray}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.categoryText,
|
||||
isSelected && styles.selectedCategoryText
|
||||
]}
|
||||
>
|
||||
{category.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const GenreButton = React.memo(({
|
||||
genre,
|
||||
isSelected,
|
||||
onPress
|
||||
}: {
|
||||
genre: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.genreButton,
|
||||
isSelected && styles.selectedGenreButton
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.genreText,
|
||||
isSelected && styles.selectedGenreText
|
||||
]}
|
||||
>
|
||||
{genre}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const ContentItem = React.memo(({
|
||||
item,
|
||||
onPress
|
||||
}: {
|
||||
item: StreamingContent;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
const { width } = Dimensions.get('window');
|
||||
const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.contentItem, { width: itemWidth }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<View style={styles.posterContainer}>
|
||||
<Image
|
||||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||
style={styles.poster}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
transition={300}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.85)']}
|
||||
style={styles.posterGradient}
|
||||
>
|
||||
<Text style={styles.contentTitle} numberOfLines={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.year && (
|
||||
<Text style={styles.contentYear}>{item.year}</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const CatalogSection = React.memo(({
|
||||
catalog,
|
||||
selectedCategory,
|
||||
navigation
|
||||
}: {
|
||||
catalog: GenreCatalog;
|
||||
selectedCategory: Category;
|
||||
navigation: NavigationProp<RootStackParamList>;
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
const { width } = Dimensions.get('window');
|
||||
const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing
|
||||
|
||||
const displayItems = useMemo(() =>
|
||||
catalog.items.slice(0, 3),
|
||||
[catalog.items]
|
||||
);
|
||||
|
||||
const handleContentPress = useCallback((item: StreamingContent) => {
|
||||
navigation.navigate('Metadata', { id: item.id, type: item.type });
|
||||
}, [navigation]);
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: StreamingContent }) => (
|
||||
<ContentItem
|
||||
item={item}
|
||||
onPress={() => handleContentPress(item)}
|
||||
/>
|
||||
), [handleContentPress]);
|
||||
|
||||
const handleSeeMorePress = useCallback(() => {
|
||||
// Get addon/catalog info from the first item (assuming homogeneity)
|
||||
const firstItem = catalog.items[0];
|
||||
if (!firstItem) return; // Should not happen if section exists
|
||||
|
||||
// We need addonId and catalogId. These aren't directly on StreamingContent.
|
||||
// We might need to fetch this or adjust the GenreCatalog structure.
|
||||
// FOR NOW: Assuming CatalogScreen can handle potentially missing addonId/catalogId
|
||||
// OR: We could pass the *genre* as the name and let CatalogScreen figure it out?
|
||||
// Let's pass the necessary info if available, assuming StreamingContent might have it
|
||||
// (Requires checking StreamingContent interface or how it's populated)
|
||||
|
||||
// --- TEMPORARY/PLACEHOLDER ---
|
||||
// Ideally, GenreCatalog should contain addonId/catalogId for the group.
|
||||
// If not, CatalogScreen needs modification or we fetch IDs here.
|
||||
// Let's stick to passing genre and type for now, CatalogScreen logic might suffice?
|
||||
navigation.navigate('Catalog', {
|
||||
// Don't pass an addonId since we want to filter by genre across all addons
|
||||
id: catalog.genre,
|
||||
type: selectedCategory.type,
|
||||
name: `${catalog.genre} ${selectedCategory.name}`,
|
||||
genreFilter: catalog.genre // This will trigger the genre-based filtering logic in CatalogScreen
|
||||
});
|
||||
// --- END TEMPORARY ---
|
||||
|
||||
}, [navigation, selectedCategory, catalog.genre, catalog.items]);
|
||||
|
||||
const keyExtractor = useCallback((item: StreamingContent) => item.id, []);
|
||||
const ItemSeparator = useCallback(() => <View style={{ width: 16 }} />, []);
|
||||
|
||||
return (
|
||||
<View style={styles.catalogContainer}>
|
||||
<View style={styles.catalogHeader}>
|
||||
<View style={styles.catalogTitleContainer}>
|
||||
<Text style={styles.catalogTitle}>{catalog.genre}</Text>
|
||||
<View style={styles.catalogTitleBar} />
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleSeeMorePress}
|
||||
style={styles.seeAllButton}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<Text style={styles.seeAllText}>See All</Text>
|
||||
<MaterialIcons name="arrow-forward-ios" color={colors.primary} size={14} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={displayItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
snapToInterval={itemWidth + 16}
|
||||
decelerationRate="fast"
|
||||
snapToAlignment="start"
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={3}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
// Extract styles into a hook for better performance with dimensions
|
||||
const useStyles = () => {
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
headerBackground: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.darkBackground,
|
||||
zIndex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 8,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 2,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: colors.white,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
searchButton: {
|
||||
padding: 10,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
categoryContainer: {
|
||||
paddingVertical: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
categoriesContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
gap: 16,
|
||||
},
|
||||
categoryButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
flex: 1,
|
||||
maxWidth: 160,
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
selectedCategoryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
categoryText: {
|
||||
color: colors.mediumGray,
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
},
|
||||
selectedCategoryText: {
|
||||
color: colors.white,
|
||||
fontWeight: '700',
|
||||
},
|
||||
genreContainer: {
|
||||
paddingTop: 20,
|
||||
paddingBottom: 12,
|
||||
zIndex: 10,
|
||||
},
|
||||
genresScrollView: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
genreButton: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 10,
|
||||
marginRight: 12,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
selectedGenreButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
genreText: {
|
||||
color: colors.mediumGray,
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
},
|
||||
selectedGenreText: {
|
||||
color: colors.white,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
catalogsContainer: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
catalogContainer: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
catalogHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
catalogTitleContainer: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
catalogTitleBar: {
|
||||
width: 32,
|
||||
height: 3,
|
||||
backgroundColor: colors.primary,
|
||||
marginTop: 6,
|
||||
borderRadius: 2,
|
||||
},
|
||||
catalogTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
},
|
||||
seeAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
seeAllText: {
|
||||
color: colors.primary,
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
contentItem: {
|
||||
marginHorizontal: 0,
|
||||
},
|
||||
posterContainer: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
elevation: 5,
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
poster: {
|
||||
aspectRatio: 2/3,
|
||||
width: '100%',
|
||||
},
|
||||
posterGradient: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
justifyContent: 'flex-end',
|
||||
height: '45%',
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
marginBottom: 4,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
contentYear: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 80,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.mediumGray,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
});
|
||||
};
|
||||
// Styles
|
||||
import useDiscoverStyles from '../styles/screens/discoverStyles';
|
||||
|
||||
const DiscoverScreen = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category>(CATEGORIES[0]);
|
||||
const [selectedGenre, setSelectedGenre] = useState<string>('All');
|
||||
const [catalogs, setCatalogs] = useState<GenreCatalog[]>([]);
|
||||
const [allContent, setAllContent] = useState<StreamingContent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const styles = useStyles();
|
||||
const styles = useDiscoverStyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Force consistent status bar settings
|
||||
|
|
@ -539,8 +73,6 @@ const DiscoverScreen = () => {
|
|||
content.push(...catalog.items);
|
||||
});
|
||||
|
||||
setAllContent(content);
|
||||
|
||||
if (genre === 'All') {
|
||||
// Group by genres when "All" is selected
|
||||
const genreCatalogs: GenreCatalog[] = [];
|
||||
|
|
@ -578,7 +110,6 @@ const DiscoverScreen = () => {
|
|||
} catch (error) {
|
||||
logger.error('Failed to load content:', error);
|
||||
setCatalogs([]);
|
||||
setAllContent([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -601,29 +132,25 @@ const DiscoverScreen = () => {
|
|||
navigation.navigate('Search');
|
||||
}, [navigation]);
|
||||
|
||||
// Memoize rendering functions
|
||||
const renderCatalogItem = useCallback(({ item }: { item: GenreCatalog }) => (
|
||||
<CatalogSection
|
||||
catalog={item}
|
||||
selectedCategory={selectedCategory}
|
||||
navigation={navigation}
|
||||
/>
|
||||
), [selectedCategory, navigation]);
|
||||
|
||||
// Memoize list key extractor
|
||||
const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []);
|
||||
|
||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
|
||||
const headerHeight = headerBaseHeight + topSpacing;
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Fixed position header background to prevent shifts */}
|
||||
{/* Fixed position header background */}
|
||||
<View style={[styles.headerBackground, { height: headerHeight }]} />
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Header Section with proper top spacing */}
|
||||
{/* Header Section */}
|
||||
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
|
||||
<View style={styles.headerContent}>
|
||||
<Text style={styles.headerTitle}>Discover</Text>
|
||||
|
|
@ -641,41 +168,21 @@ const DiscoverScreen = () => {
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* Rest of the content */}
|
||||
{/* Content Container */}
|
||||
<View style={styles.contentContainer}>
|
||||
{/* Categories Section */}
|
||||
<View style={styles.categoryContainer}>
|
||||
<View style={styles.categoriesContent}>
|
||||
{CATEGORIES.map((category) => (
|
||||
<CategoryButton
|
||||
key={category.id}
|
||||
category={category}
|
||||
isSelected={selectedCategory.id === category.id}
|
||||
onPress={() => handleCategoryPress(category)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<CategorySelector
|
||||
categories={CATEGORIES}
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={handleCategoryPress}
|
||||
/>
|
||||
|
||||
{/* Genres Section */}
|
||||
<View style={styles.genreContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.genresScrollView}
|
||||
decelerationRate="fast"
|
||||
snapToInterval={10}
|
||||
>
|
||||
{COMMON_GENRES.map(genre => (
|
||||
<GenreButton
|
||||
key={genre}
|
||||
genre={genre}
|
||||
isSelected={selectedGenre === genre}
|
||||
onPress={() => handleGenrePress(genre)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<GenreSelector
|
||||
genres={COMMON_GENRES}
|
||||
selectedGenre={selectedGenre}
|
||||
onSelectGenre={handleGenrePress}
|
||||
/>
|
||||
|
||||
{/* Content Section */}
|
||||
{loading ? (
|
||||
|
|
@ -683,24 +190,11 @@ const DiscoverScreen = () => {
|
|||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
) : catalogs.length > 0 ? (
|
||||
<FlatList
|
||||
data={catalogs}
|
||||
renderItem={renderCatalogItem}
|
||||
keyExtractor={catalogKeyExtractor}
|
||||
contentContainerStyle={styles.catalogsContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
<CatalogsList
|
||||
catalogs={catalogs}
|
||||
selectedCategory={selectedCategory}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
) : renderEmptyState()}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
|||
67
src/styles/screens/discoverStyles.ts
Normal file
67
src/styles/screens/discoverStyles.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { StyleSheet, Dimensions } from 'react-native';
|
||||
import { colors } from '../index';
|
||||
|
||||
const useDiscoverStyles = () => {
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
headerBackground: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.darkBackground,
|
||||
zIndex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 8,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 2,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: colors.white,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
searchButton: {
|
||||
padding: 10,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 80,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.mediumGray,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default useDiscoverStyles;
|
||||
Loading…
Reference in a new issue