NuvioStreaming/src/screens/ShowRatingsScreen.tsx
tapframe 4a6f349cdb Refactor animation durations across multiple components for improved performance and consistency
This update modifies the animation durations in various components, including CatalogSection, ContinueWatchingSection, FeaturedContent, and ThisWeekSection, reducing the fade-in durations to enhance the user experience. Additionally, adjustments were made to the CastSection, MetadataDetails, and SeriesContent components to streamline animations. The changes aim to create a more cohesive and responsive interface throughout the application.
2025-06-21 18:36:20 +05:30

783 lines
No EOL
25 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback, memo, Suspense } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
ActivityIndicator,
SafeAreaView,
TouchableOpacity,
Platform,
StatusBar,
} from 'react-native';
import { Image } from 'expo-image';
import { BlurView } from 'expo-blur';
import { useTheme } from '../contexts/ThemeContext';
import { TMDBService, TMDBShow as Show, TMDBSeason, TMDBEpisode } from '../services/tmdbService';
import { RouteProp } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import axios from 'axios';
import Animated, { FadeIn, SlideInRight, withTiming, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { logger } from '../utils/logger';
type RootStackParamList = {
ShowRatings: { showId: number };
};
type ShowRatingsRouteProp = RouteProp<RootStackParamList, 'ShowRatings'>;
type RatingSource = 'tmdb' | 'imdb' | 'tvmaze';
interface TVMazeEpisode {
id: number;
rating: {
average: number | null;
};
season: number;
number: number;
}
interface TVMazeShow {
id: number;
externals: {
imdb: string | null;
thetvdb: number | null;
};
_embedded?: {
episodes: TVMazeEpisode[];
};
}
interface Props {
route: ShowRatingsRouteProp;
}
const getRatingColor = (rating: number): string => {
if (rating >= 9.0) return '#186A3B'; // Awesome
if (rating >= 8.5) return '#28B463'; // Great
if (rating >= 8.0) return '#28B463'; // Great
if (rating >= 7.5) return '#F4D03F'; // Good
if (rating >= 7.0) return '#F39C12'; // Regular
if (rating >= 6.0) return '#E74C3C'; // Bad
return '#633974'; // Garbage
};
// Memoized components
const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, isCurrentSeason, theme }: {
episode: TMDBEpisode;
ratingSource: RatingSource;
getTVMazeRating: (seasonNumber: number, episodeNumber: number) => number | null;
isCurrentSeason: (episode: TMDBEpisode) => boolean;
theme: any;
}) => {
const getRatingForSource = useCallback((episode: TMDBEpisode): number | null => {
switch (ratingSource) {
case 'imdb':
return episode.imdb_rating || null;
case 'tmdb':
return episode.vote_average || null;
case 'tvmaze':
return getTVMazeRating(episode.season_number, episode.episode_number);
default:
return null;
}
}, [ratingSource, getTVMazeRating]);
const isRatingPotentiallyInaccurate = useCallback((episode: TMDBEpisode): boolean => {
const rating = getRatingForSource(episode);
if (!rating) return false;
if (ratingSource === 'tmdb' && episode.imdb_rating) {
const difference = Math.abs(rating - episode.imdb_rating);
return difference >= 2;
}
return false;
}, [getRatingForSource, ratingSource]);
const rating = getRatingForSource(episode);
const isInaccurate = isRatingPotentiallyInaccurate(episode);
const isCurrent = isCurrentSeason(episode);
if (!rating) {
if (!episode.air_date || new Date(episode.air_date) > new Date()) {
return (
<View style={[styles.ratingCell, { backgroundColor: theme.colors.darkGray }]}>
<MaterialIcons name="schedule" size={16} color={theme.colors.lightGray} />
</View>
);
}
return (
<View style={[styles.ratingCell, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.ratingText, { color: theme.colors.lightGray }]}></Text>
</View>
);
}
return (
<Animated.View style={styles.ratingCellContainer}>
<Animated.View style={[
styles.ratingCell,
{
backgroundColor: getRatingColor(rating),
opacity: isCurrent ? 0.7 : 1,
}
]}>
<Text style={styles.ratingText}>{rating.toFixed(1)}</Text>
</Animated.View>
{(isInaccurate || isCurrent) && (
<MaterialIcons
name={isCurrent ? "schedule" : "warning"}
size={12}
color={isCurrent ? theme.colors.primary : theme.colors.warning}
style={styles.warningIcon}
/>
)}
</Animated.View>
);
});
const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: {
ratingSource: RatingSource;
setRatingSource: (source: RatingSource) => void;
theme: any;
}) => (
<View style={styles.ratingSourceContainer}>
<Text style={[styles.sectionTitle, { color: theme.colors.white }]}>Rating Source:</Text>
<View style={styles.ratingSourceButtons}>
{['imdb', 'tmdb', 'tvmaze'].map((source) => {
const isActive = ratingSource === source;
return (
<TouchableOpacity
key={source}
style={[
styles.sourceButton,
{ borderColor: theme.colors.lightGray },
isActive && { backgroundColor: theme.colors.primary, borderColor: theme.colors.primary }
]}
onPress={() => setRatingSource(source as RatingSource)}
>
<Text
style={{
fontSize: 13,
fontWeight: isActive ? '700' : '600',
color: isActive ? theme.colors.white : theme.colors.lightGray
}}
>
{source.toUpperCase()}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
));
const ShowInfo = memo(({ show, theme }: { show: Show | null, theme: any }) => (
<View style={styles.showInfo}>
<Image
source={{ uri: `https://image.tmdb.org/t/p/w500${show?.poster_path}` }}
style={styles.poster}
contentFit="cover"
transition={200}
/>
<View style={styles.showDetails}>
<Text style={[styles.showTitle, { color: theme.colors.white }]}>{show?.name}</Text>
<Text style={[styles.showYear, { color: theme.colors.lightGray }]}>
{show?.first_air_date ? `${new Date(show.first_air_date).getFullYear()} - ${show.last_air_date ? new Date(show.last_air_date).getFullYear() : 'Present'}` : ''}
</Text>
<View style={styles.episodeCountContainer}>
<MaterialIcons name="tv" size={16} color={theme.colors.primary} />
<Text style={[styles.episodeCount, { color: theme.colors.lightGray }]}>
{show?.number_of_seasons} Seasons {show?.number_of_episodes} Episodes
</Text>
</View>
</View>
</View>
));
const ShowRatingsScreen = ({ route }: Props) => {
const { currentTheme } = useTheme();
const { colors } = currentTheme;
const { showId } = route.params;
const [show, setShow] = useState<Show | null>(null);
const [seasons, setSeasons] = useState<TMDBSeason[]>([]);
const [tvmazeEpisodes, setTvmazeEpisodes] = useState<TVMazeEpisode[]>([]);
const [loading, setLoading] = useState(true);
const [loadingSeasons, setLoadingSeasons] = useState(false);
const [loadedSeasons, setLoadedSeasons] = useState<number[]>([]);
const [ratingSource, setRatingSource] = useState<RatingSource>('tmdb');
const [visibleSeasonRange, setVisibleSeasonRange] = useState({ start: 0, end: 8 });
const [loadingProgress, setLoadingProgress] = useState(0);
const ratingsCache = useRef<{[key: string]: number | null}>({});
const fetchTVMazeData = async (imdbId: string) => {
try {
const lookupResponse = await axios.get(`https://api.tvmaze.com/lookup/shows?imdb=${imdbId}`);
const tvmazeId = lookupResponse.data?.id;
if (tvmazeId) {
const showResponse = await axios.get(`https://api.tvmaze.com/shows/${tvmazeId}?embed=episodes`);
if (showResponse.data?._embedded?.episodes) {
setTvmazeEpisodes(showResponse.data._embedded.episodes);
}
}
} catch (error) {
logger.error('Error fetching TVMaze data:', error);
}
};
const loadMoreSeasons = async () => {
if (!show || loadingSeasons) return;
setLoadingSeasons(true);
try {
const tmdb = TMDBService.getInstance();
const seasonsToLoad = show.seasons
.filter(season =>
season.season_number > 0 &&
!loadedSeasons.includes(season.season_number) &&
season.season_number > visibleSeasonRange.start &&
season.season_number <= visibleSeasonRange.end
);
// Load seasons in parallel in larger batches
const batchSize = 4; // Load 4 seasons at a time
const batches = [];
for (let i = 0; i < seasonsToLoad.length; i += batchSize) {
const batch = seasonsToLoad.slice(i, i + batchSize);
batches.push(batch);
}
let loadedCount = 0;
const totalToLoad = seasonsToLoad.length;
for (const batch of batches) {
const batchResults = await Promise.all(
batch.map(season =>
tmdb.getSeasonDetails(showId, season.season_number, show.name)
)
);
const validResults = batchResults.filter((s): s is TMDBSeason => s !== null);
setSeasons(prev => [...prev, ...validResults]);
setLoadedSeasons(prev => [...prev, ...batch.map(s => s.season_number)]);
loadedCount += batch.length;
setLoadingProgress((loadedCount / totalToLoad) * 100);
}
} catch (error) {
logger.error('Error loading more seasons:', error);
} finally {
setLoadingProgress(0);
setLoadingSeasons(false);
}
};
const onScroll = useCallback((event: any) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const isCloseToRight = (contentOffset.x + layoutMeasurement.width) >= (contentSize.width * 0.8);
if (isCloseToRight && show && !loadingSeasons) {
const maxSeasons = Math.max(...show.seasons.map(s => s.season_number));
if (visibleSeasonRange.end < maxSeasons) {
setVisibleSeasonRange(prev => ({
start: prev.end,
end: Math.min(prev.end + 8, maxSeasons)
}));
}
}
}, [show, loadingSeasons, visibleSeasonRange.end]);
useEffect(() => {
const fetchShowData = async () => {
try {
const tmdb = TMDBService.getInstance();
// Log the showId being used
logger.log(`[ShowRatingsScreen] Fetching show details for ID: ${showId}`);
const showData = await tmdb.getTVShowDetails(showId);
if (showData) {
setShow(showData);
// Get external IDs to fetch TVMaze data
const externalIds = await tmdb.getShowExternalIds(showId);
if (externalIds?.imdb_id) {
fetchTVMazeData(externalIds.imdb_id);
}
// Set initial season range
const initialEnd = Math.min(8, Math.max(...showData.seasons.map(s => s.season_number)));
setVisibleSeasonRange({ start: 0, end: initialEnd });
}
} catch (error) {
logger.error('Error fetching show data:', error);
} finally {
setLoading(false);
}
};
fetchShowData();
}, [showId]);
useEffect(() => {
loadMoreSeasons();
}, [visibleSeasonRange]);
const getTVMazeRating = useCallback((seasonNumber: number, episodeNumber: number): number | null => {
const episode = tvmazeEpisodes.find(
ep => ep.season === seasonNumber && ep.number === episodeNumber
);
return episode?.rating?.average || null;
}, [tvmazeEpisodes]);
const isCurrentSeason = useCallback((episode: TMDBEpisode): boolean => {
if (!seasons.length || !episode.air_date) return false;
const latestSeasonNumber = Math.max(...seasons.map(s => s.season_number));
if (episode.season_number !== latestSeasonNumber) return false;
const now = new Date();
const airDate = new Date(episode.air_date);
const monthsDiff = (now.getFullYear() - airDate.getFullYear()) * 12 +
(now.getMonth() - airDate.getMonth());
return monthsDiff <= 6;
}, [seasons]);
if (loading) {
return (
<View style={[styles.container, { backgroundColor: colors.black }]}>
<StatusBar
translucent
backgroundColor="transparent"
barStyle="light-content"
/>
<SafeAreaView style={{ flex: 1 }}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.lightGray }]}>Loading show data...</Text>
</View>
</SafeAreaView>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.black }]}>
{Platform.OS === 'ios' && (
<BlurView
style={StyleSheet.absoluteFill}
tint="dark"
intensity={60}
/>
)}
<StatusBar
translucent
backgroundColor="transparent"
barStyle="light-content"
/>
<SafeAreaView style={{ flex: 1 }}>
<Suspense fallback={
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.lightGray }]}>Loading content...</Text>
</View>
}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
contentContainerStyle={styles.scrollViewContent}
>
<View style={styles.content}>
<Animated.View
entering={FadeIn.duration(300)}
style={styles.showInfoContainer}
>
<ShowInfo show={show} theme={currentTheme} />
</Animated.View>
<Animated.View
entering={FadeIn.delay(100).duration(300)}
style={styles.section}
>
<RatingSourceToggle
ratingSource={ratingSource}
setRatingSource={setRatingSource}
theme={currentTheme}
/>
</Animated.View>
<Animated.View
entering={FadeIn.delay(200).duration(300)}
style={styles.section}
>
{/* Legend */}
<View style={[styles.legend, { backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground }]}>
<Text style={[styles.sectionTitle, { color: colors.white }]}>Rating Scale</Text>
<View style={styles.legendItems}>
{[
{ color: '#186A3B', text: 'Awesome (9.0+)' },
{ color: '#28B463', text: 'Great (8.0-8.9)' },
{ color: '#F4D03F', text: 'Good (7.5-7.9)' },
{ color: '#F39C12', text: 'Regular (7.0-7.4)' },
{ color: '#E74C3C', text: 'Bad (6.0-6.9)' },
{ color: '#633974', text: 'Garbage (<6.0)' }
].map((item, index) => (
<View key={index} style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: item.color }]} />
<Text style={[styles.legendText, { color: colors.lightGray }]}>{item.text}</Text>
</View>
))}
</View>
<View style={[styles.warningLegends, { borderTopColor: colors.black + '40' }]}>
<View style={styles.warningLegend}>
<MaterialIcons name="warning" size={14} color={colors.warning} />
<Text style={[styles.warningText, { color: colors.lightGray }]}>Rating differs significantly from IMDb</Text>
</View>
<View style={styles.warningLegend}>
<MaterialIcons name="schedule" size={14} color={colors.primary} />
<Text style={[styles.warningText, { color: colors.lightGray }]}>Current season (ratings may change)</Text>
</View>
</View>
</View>
</Animated.View>
<Animated.View
entering={FadeIn.delay(300).duration(300)}
style={styles.section}
>
{/* Ratings Grid */}
<Text style={[styles.sectionTitle, { color: colors.white }]}>Episode Ratings</Text>
<View style={[styles.ratingsGrid, { backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground }]}>
<View style={styles.gridContainer}>
{/* Fixed Episode Column */}
<View style={[styles.fixedColumn, { borderRightColor: colors.black + '40' }]}>
<View style={styles.episodeColumn}>
<Text style={[styles.headerText, { color: colors.white }]}>Episode</Text>
</View>
{Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => (
<View key={`e${episodeIndex + 1}`} style={styles.episodeCell}>
<Text style={[styles.episodeText, { color: colors.lightGray }]}>E{episodeIndex + 1}</Text>
</View>
))}
</View>
{/* Scrollable Seasons */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.seasonsScrollView}
onScroll={onScroll}
scrollEventThrottle={16}
>
<View>
{/* Seasons Header */}
<View style={[styles.gridHeader, { borderBottomColor: colors.black + '40' }]}>
{seasons.map((season) => (
<Animated.View
key={`s${season.season_number}`}
style={styles.ratingColumn}
entering={FadeIn.delay(season.season_number * 20).duration(200)}
>
<Text style={[styles.headerText, { color: colors.white }]}>S{season.season_number}</Text>
</Animated.View>
))}
{loadingSeasons && (
<View style={[styles.ratingColumn, styles.loadingColumn]}>
<View style={styles.loadingProgressContainer}>
<ActivityIndicator size="small" color={colors.primary} />
{loadingProgress > 0 && (
<Text style={[styles.loadingProgressText, { color: colors.primary }]}>
{Math.round(loadingProgress)}%
</Text>
)}
</View>
</View>
)}
</View>
{/* Episodes Grid */}
{Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => (
<View key={`e${episodeIndex + 1}`} style={styles.gridRow}>
{seasons.map((season) => (
<Animated.View
key={`s${season.season_number}e${episodeIndex + 1}`}
style={styles.ratingColumn}
entering={FadeIn.delay((season.season_number + episodeIndex) * 5).duration(200)}
>
{season.episodes[episodeIndex] &&
<RatingCell
episode={season.episodes[episodeIndex]}
ratingSource={ratingSource}
getTVMazeRating={getTVMazeRating}
isCurrentSeason={isCurrentSeason}
theme={currentTheme}
/>
}
</Animated.View>
))}
{loadingSeasons && <View style={[styles.ratingColumn, styles.loadingColumn]} />}
</View>
))}
</View>
</ScrollView>
</View>
</View>
</Animated.View>
</View>
</ScrollView>
</Suspense>
</SafeAreaView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollViewContent: {
flexGrow: 1,
},
content: {
padding: 12,
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 12 : 12,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
loadingText: {
fontSize: 14,
fontWeight: '500',
},
showInfoContainer: {
marginBottom: 12,
},
section: {
marginBottom: 12,
},
showInfo: {
flexDirection: 'row',
borderRadius: 8,
padding: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
poster: {
width: 80,
height: 120,
borderRadius: 6,
},
showDetails: {
flex: 1,
marginLeft: 12,
justifyContent: 'center',
},
showTitle: {
fontSize: 18,
fontWeight: '800',
marginBottom: 4,
letterSpacing: 0.5,
},
showYear: {
fontSize: 13,
marginBottom: 4,
},
episodeCountContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 4,
},
episodeCount: {
fontSize: 13,
},
ratingSourceContainer: {
marginBottom: 12,
},
sectionTitle: {
fontSize: 15,
fontWeight: '700',
marginBottom: 8,
letterSpacing: 0.5,
},
ratingSourceButtons: {
flexDirection: 'row',
gap: 8,
},
sourceButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
borderWidth: 1,
flex: 1,
alignItems: 'center',
},
sourceButtonActive: {
fontWeight: '700',
},
sourceButtonText: {
fontSize: 13,
fontWeight: '600',
},
sourceButtonTextActive: {
fontWeight: '700',
},
legend: {
borderRadius: 8,
padding: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
legendItems: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
marginBottom: 12,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
width: '48%',
marginBottom: 8,
},
legendColor: {
width: 12,
height: 12,
borderRadius: 3,
marginRight: 6,
},
legendText: {
fontSize: 12,
},
warningLegends: {
marginTop: 8,
gap: 4,
borderTopWidth: 1,
paddingTop: 8,
},
warningLegend: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
warningText: {
fontSize: 12,
flex: 1,
},
ratingsGrid: {
borderRadius: 8,
padding: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
gridContainer: {
flexDirection: 'row',
},
fixedColumn: {
width: 40,
borderRightWidth: 1,
paddingRight: 6,
},
seasonsScrollView: {
flex: 1,
},
gridHeader: {
flexDirection: 'row',
marginBottom: 8,
borderBottomWidth: 1,
paddingBottom: 6,
paddingLeft: 6,
},
gridRow: {
flexDirection: 'row',
marginBottom: 6,
paddingLeft: 6,
},
episodeCell: {
height: 26,
justifyContent: 'center',
paddingRight: 6,
},
episodeColumn: {
height: 26,
justifyContent: 'center',
marginBottom: 8,
paddingRight: 6,
},
ratingColumn: {
width: 40,
alignItems: 'center',
},
headerText: {
fontWeight: '700',
fontSize: 13,
letterSpacing: 0.5,
},
episodeText: {
fontSize: 13,
fontWeight: '500',
},
ratingCell: {
width: 32,
height: 26,
borderRadius: 4,
justifyContent: 'center',
alignItems: 'center',
},
ratingText: {
color: 'white',
fontSize: 12,
fontWeight: '700',
},
ratingCellContainer: {
position: 'relative',
width: 32,
height: 26,
},
warningIcon: {
position: 'absolute',
top: -4,
right: -4,
backgroundColor: 'black',
borderRadius: 8,
padding: 1,
},
loadingColumn: {
width: 40,
justifyContent: 'center',
alignItems: 'center',
opacity: 0.7,
},
loadingProgressContainer: {
alignItems: 'center',
gap: 4,
},
loadingProgressText: {
fontSize: 10,
fontWeight: '600',
},
});
export default memo(ShowRatingsScreen, (prevProps, nextProps) => {
return prevProps.route.params.showId === nextProps.route.params.showId;
});