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; 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 ( ); } return ( ); } return ( {rating.toFixed(1)} {(isInaccurate || isCurrent) && ( )} ); }); const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: { ratingSource: RatingSource; setRatingSource: (source: RatingSource) => void; theme: any; }) => ( Rating Source: {['imdb', 'tmdb', 'tvmaze'].map((source) => { const isActive = ratingSource === source; return ( setRatingSource(source as RatingSource)} > {source.toUpperCase()} ); })} )); const ShowInfo = memo(({ show, theme }: { show: Show | null, theme: any }) => ( {show?.name} {show?.first_air_date ? `${new Date(show.first_air_date).getFullYear()} - ${show.last_air_date ? new Date(show.last_air_date).getFullYear() : 'Present'}` : ''} {show?.number_of_seasons} Seasons • {show?.number_of_episodes} Episodes )); const ShowRatingsScreen = ({ route }: Props) => { const { currentTheme } = useTheme(); const { colors } = currentTheme; const { showId } = route.params; const [show, setShow] = useState(null); const [seasons, setSeasons] = useState([]); const [tvmazeEpisodes, setTvmazeEpisodes] = useState([]); const [loading, setLoading] = useState(true); const [loadingSeasons, setLoadingSeasons] = useState(false); const [loadedSeasons, setLoadedSeasons] = useState([]); const [ratingSource, setRatingSource] = useState('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 ( Loading show data... ); } return ( {Platform.OS === 'ios' && ( )} Loading content... }> {/* Legend */} Rating Scale {[ { 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) => ( {item.text} ))} Rating differs significantly from IMDb Current season (ratings may change) {/* Ratings Grid */} Episode Ratings {/* Fixed Episode Column */} Episode {Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => ( E{episodeIndex + 1} ))} {/* Scrollable Seasons */} {/* Seasons Header */} {seasons.map((season) => ( S{season.season_number} ))} {loadingSeasons && ( {loadingProgress > 0 && ( {Math.round(loadingProgress)}% )} )} {/* Episodes Grid */} {Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => ( {seasons.map((season) => ( {season.episodes[episodeIndex] && } ))} {loadingSeasons && } ))} ); }; 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; });