NuvioStreaming/src/components/home/ThisWeekSection.tsx

349 lines
No EOL
10 KiB
TypeScript

import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
Dimensions
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
import { useTraktContext } from '../../contexts/TraktContext';
import { useLibrary } from '../../hooks/useLibrary';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns';
import Animated, { FadeIn } from 'react-native-reanimated';
import { useCalendarData } from '../../hooks/useCalendarData';
import { memoryManager } from '../../utils/memoryManager';
import { tmdbService } from '../../services/tmdbService';
// Compute base sizes; actual tablet sizes will be adjusted inside component for responsiveness
const { width } = Dimensions.get('window');
const ITEM_WIDTH = width * 0.75; // phone default
const ITEM_HEIGHT = 180; // phone default
interface ThisWeekEpisode {
id: string;
seriesId: string;
seriesName: string;
title: string;
poster: string;
releaseDate: string;
season: number;
episode: number;
isReleased: boolean;
overview: string;
vote_average: number;
still_path: string | null;
season_poster_path: string | null;
}
export const ThisWeekSection = React.memo(() => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { calendarData, loading } = useCalendarData();
// Responsive sizing for tablets
const deviceWidth = Dimensions.get('window').width;
const isTablet = deviceWidth >= 768;
const computedItemWidth = useMemo(() => (isTablet ? Math.min(deviceWidth * 0.46, 560) : ITEM_WIDTH), [isTablet, deviceWidth]);
const computedItemHeight = useMemo(() => (isTablet ? 220 : ITEM_HEIGHT), [isTablet]);
// Use the already memory-optimized calendar data instead of fetching separately
const thisWeekEpisodes = useMemo(() => {
const thisWeekSection = calendarData.find(section => section.title === 'This Week');
if (!thisWeekSection) return [];
// Limit episodes to prevent memory issues and add release status
const episodes = memoryManager.limitArraySize(thisWeekSection.data, 20); // Limit to 20 for home screen
return episodes.map(episode => ({
...episode,
isReleased: episode.releaseDate ? isBefore(parseISO(episode.releaseDate), new Date()) : false,
}));
}, [calendarData]);
const handleEpisodePress = (episode: ThisWeekEpisode) => {
// For upcoming episodes, go to the metadata screen
if (!episode.isReleased) {
const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`;
navigation.navigate('Metadata', {
id: episode.seriesId,
type: 'series',
episodeId
});
return;
}
// For released episodes, go to the streams screen
const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`;
navigation.navigate('Streams', {
id: episode.seriesId,
type: 'series',
episodeId
});
};
const handleViewAll = () => {
navigation.navigate('Calendar' as any);
};
if (thisWeekEpisodes.length === 0) {
return null;
}
const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
// Handle episodes without release dates gracefully
const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null;
const formattedDate = releaseDate ? format(releaseDate, 'E, MMM d') : 'TBA';
const isReleased = item.isReleased;
// Use episode still image if available, fallback to series poster
const imageUrl = item.still_path ?
tmdbService.getImageUrl(item.still_path) :
(item.season_poster_path ?
tmdbService.getImageUrl(item.season_poster_path) :
item.poster);
return (
<View style={[styles.episodeItemContainer, { width: computedItemWidth, height: computedItemHeight }]}>
<TouchableOpacity
style={[
styles.episodeItem,
{
shadowColor: currentTheme.colors.black,
backgroundColor: currentTheme.colors.background,
}
]}
onPress={() => handleEpisodePress(item)}
activeOpacity={0.8}
>
<View style={styles.imageContainer}>
<Image
source={{ uri: imageUrl }}
style={styles.poster}
contentFit="cover"
transition={0}
/>
{/* Enhanced gradient overlay */}
<LinearGradient
colors={[
'transparent',
'transparent',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.95)'
]}
style={styles.gradient}
locations={[0, 0.4, 0.6, 0.8, 1]}
>
{/* Content area */}
<View style={styles.contentArea}>
<Text style={[styles.seriesName, { color: currentTheme.colors.white, fontSize: isTablet ? 18 : undefined }]} numberOfLines={1}>
{item.seriesName}
</Text>
<Text style={[styles.episodeTitle, { color: 'rgba(255,255,255,0.9)', fontSize: isTablet ? 16 : undefined }]} numberOfLines={2}>
{item.title}
</Text>
{item.overview && (
<Text style={[styles.overview, { color: 'rgba(255,255,255,0.8)', fontSize: isTablet ? 13 : undefined }]} numberOfLines={isTablet ? 3 : 2}>
{item.overview}
</Text>
)}
<View style={styles.dateContainer}>
<Text style={[styles.episodeInfo, { color: 'rgba(255,255,255,0.7)', fontSize: isTablet ? 13 : undefined }]}>
S{item.season}:E{item.episode}
</Text>
<MaterialIcons
name="event"
size={isTablet ? 16 : 14}
color={currentTheme.colors.primary}
/>
<Text style={[styles.releaseDate, { color: currentTheme.colors.primary, fontSize: isTablet ? 14 : undefined }]}>
{formattedDate}
</Text>
</View>
</View>
</LinearGradient>
</View>
</TouchableOpacity>
</View>
);
};
return (
<Animated.View style={styles.container} entering={FadeIn.duration(350)}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text>
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} />
</View>
<TouchableOpacity onPress={handleViewAll} style={styles.viewAllButton}>
<Text style={[styles.viewAllText, { color: currentTheme.colors.textMuted }]}>View All</Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} />
</TouchableOpacity>
</View>
<FlatList
data={thisWeekEpisodes}
keyExtractor={(item) => item.id}
renderItem={renderEpisodeItem}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.listContent, { paddingLeft: isTablet ? 24 : 16, paddingRight: isTablet ? 24 : 16 }]}
snapToInterval={computedItemWidth + 16}
decelerationRate="fast"
snapToAlignment="start"
initialNumToRender={isTablet ? 4 : 3}
windowSize={3}
maxToRenderPerBatch={3}
removeClippedSubviews
getItemLayout={(data, index) => {
const length = computedItemWidth + 16;
const offset = length * index;
return { length, offset, index };
}}
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
/>
</Animated.View>
);
});
const styles = StyleSheet.create({
container: {
marginVertical: 20,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 16,
},
titleContainer: {
position: 'relative',
},
title: {
fontSize: 24,
fontWeight: '800',
letterSpacing: 0.5,
marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
bottom: -2,
left: 0,
width: 40,
height: 3,
borderRadius: 2,
opacity: 0.8,
},
viewAllButton: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.1)',
marginRight: -10,
},
viewAllText: {
fontSize: 14,
fontWeight: '600',
marginRight: 4,
},
listContent: {
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 8,
},
loadingContainer: {
padding: 32,
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
fontWeight: '500',
},
episodeItemContainer: {
width: ITEM_WIDTH,
height: ITEM_HEIGHT,
},
episodeItem: {
width: '100%',
height: '100%',
borderRadius: 16,
overflow: 'hidden',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 12,
},
imageContainer: {
width: '100%',
height: '100%',
position: 'relative',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 16,
},
gradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
justifyContent: 'flex-end',
padding: 12,
borderRadius: 16,
},
contentArea: {
width: '100%',
},
seriesName: {
fontSize: 16,
fontWeight: '700',
marginBottom: 6,
},
episodeTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 4,
lineHeight: 18,
},
overview: {
fontSize: 12,
lineHeight: 16,
marginBottom: 6,
opacity: 0.9,
},
dateContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
episodeInfo: {
fontSize: 12,
fontWeight: '600',
marginRight: 4,
},
releaseDate: {
fontSize: 13,
fontWeight: '600',
marginLeft: 6,
letterSpacing: 0.3,
},
});