NuvioStreaming_backup_24-10-25/src/screens/ShowRatingsScreen.tsx
tapframe b8484e432f Enhance HeroSection and useMetadataAssets for improved ID handling and asset fetching
This update introduces robust ID parsing and conversion logic in the HeroSection component, allowing for seamless navigation based on TMDB and IMDb IDs. Additionally, the useMetadataAssets hook has been optimized to manage logo and banner fetching more effectively, incorporating source tracking to prevent mixing assets from different sources. The changes improve error handling and logging for better debugging and user experience. The LogoSourceSettings component has also been updated to clarify the source selection process for logos and backgrounds.
2025-05-04 00:08:33 +05:30

805 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 { colors } from '../styles';
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 }: {
episode: TMDBEpisode;
ratingSource: RatingSource;
getTVMazeRating: (seasonNumber: number, episodeNumber: number) => number | null;
isCurrentSeason: (episode: TMDBEpisode) => boolean;
}) => {
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: colors.darkGray }]}>
<MaterialIcons name="schedule" size={16} color={colors.lightGray} />
</View>
);
}
return (
<View style={[styles.ratingCell, { backgroundColor: colors.darkGray }]}>
<Text style={[styles.ratingText, { color: colors.lightGray }]}></Text>
</View>
);
}
return (
<View style={styles.ratingCellContainer}>
<View style={[
styles.ratingCell,
{
backgroundColor: getRatingColor(rating),
opacity: isCurrent ? 0.7 : 1
}
]}>
<Text style={styles.ratingText}>{rating.toFixed(1)}</Text>
</View>
{(isInaccurate || isCurrent) && (
<MaterialIcons
name={isCurrent ? "schedule" : "warning"}
size={12}
color={isCurrent ? colors.primary : colors.warning}
style={styles.warningIcon}
/>
)}
</View>
);
});
const RatingSourceToggle = memo(({ ratingSource, setRatingSource }: {
ratingSource: RatingSource;
setRatingSource: (source: RatingSource) => void;
}) => (
<View style={styles.ratingSourceContainer}>
<Text style={styles.ratingSourceTitle}>Rating Source:</Text>
<View style={styles.ratingSourceButtons}>
<TouchableOpacity
style={[
styles.sourceButton,
ratingSource === 'imdb' && styles.sourceButtonActive
]}
onPress={() => setRatingSource('imdb')}
>
<Text style={[
styles.sourceButtonText,
ratingSource === 'imdb' && styles.sourceButtonTextActive
]}>IMDb</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.sourceButton,
ratingSource === 'tmdb' && styles.sourceButtonActive
]}
onPress={() => setRatingSource('tmdb')}
>
<Text style={[
styles.sourceButtonText,
ratingSource === 'tmdb' && styles.sourceButtonTextActive
]}>TMDB</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.sourceButton,
ratingSource === 'tvmaze' && styles.sourceButtonActive
]}
onPress={() => setRatingSource('tvmaze')}
>
<Text style={[
styles.sourceButtonText,
ratingSource === 'tvmaze' && styles.sourceButtonTextActive
]}>TVMaze</Text>
</TouchableOpacity>
</View>
</View>
));
const ShowInfo = memo(({ show }: { show: Show | null }) => (
<View style={styles.showInfo}>
<Image
source={{ uri: `https://image.tmdb.org/t/p/w500${show?.poster_path}` }}
style={styles.poster}
contentFit="cover"
/>
<View style={styles.showDetails}>
<Text style={styles.showTitle}>{show?.name}</Text>
<Text style={styles.showYear}>
{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={colors.primary} />
<Text style={styles.episodeCount}>
{show?.number_of_seasons} Seasons {show?.number_of_episodes} Episodes
</Text>
</View>
</View>
</View>
));
const ShowRatingsScreen = ({ route }: Props) => {
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} />
</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} />
</View>
}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
>
<View style={styles.content}>
<Animated.View
entering={FadeIn.duration(150)}
style={styles.showInfoContainer}
>
<ShowInfo show={show} />
</Animated.View>
<Animated.View
entering={FadeIn.delay(50).duration(150)}
style={styles.ratingSourceContainer}
>
<RatingSourceToggle ratingSource={ratingSource} setRatingSource={setRatingSource} />
</Animated.View>
<Animated.View
entering={FadeIn.delay(100).duration(150)}
style={styles.legend}
>
{/* Legend */}
<View style={styles.legend}>
<Text style={styles.legendTitle}>Rating Scale</Text>
<View style={styles.legendItems}>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#186A3B' }]} />
<Text style={styles.legendText}>Awesome (9.0+)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#28B463' }]} />
<Text style={styles.legendText}>Great (8.0-8.9)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#F4D03F' }]} />
<Text style={styles.legendText}>Good (7.5-7.9)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#F39C12' }]} />
<Text style={styles.legendText}>Regular (7.0-7.4)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#E74C3C' }]} />
<Text style={styles.legendText}>Bad (6.0-6.9)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#633974' }]} />
<Text style={styles.legendText}>Garbage ({'<'}6.0)</Text>
</View>
</View>
<View style={styles.warningLegends}>
<View style={styles.warningLegend}>
<MaterialIcons name="warning" size={16} color={colors.warning} />
<Text style={styles.warningText}>Rating differs significantly from IMDb</Text>
</View>
<View style={styles.warningLegend}>
<MaterialIcons name="schedule" size={16} color={colors.primary} />
<Text style={styles.warningText}>Current season (ratings may change)</Text>
</View>
</View>
</View>
</Animated.View>
<Animated.View
entering={FadeIn.delay(150).duration(150)}
style={styles.ratingsGrid}
>
{/* Ratings Grid */}
<View style={styles.ratingsGrid}>
<View style={styles.gridContainer}>
{/* Fixed Episode Column */}
<View style={styles.fixedColumn}>
<View style={styles.episodeColumn}>
<Text style={styles.headerText}>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}>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}>
{seasons.map((season) => (
<View key={`s${season.season_number}`} style={styles.ratingColumn}>
<Text style={styles.headerText}>S{season.season_number}</Text>
</View>
))}
{loadingSeasons && (
<View style={[styles.ratingColumn, styles.loadingColumn]}>
<View style={styles.loadingProgressContainer}>
<ActivityIndicator size="small" color={colors.primary} />
{loadingProgress > 0 && (
<Text style={styles.loadingProgressText}>
{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) => (
<View key={`s${season.season_number}e${episodeIndex + 1}`} style={styles.ratingColumn}>
{season.episodes[episodeIndex] &&
<RatingCell
episode={season.episodes[episodeIndex]}
ratingSource={ratingSource}
getTVMazeRating={getTVMazeRating}
isCurrentSeason={isCurrentSeason}
/>
}
</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,
},
content: {
padding: 8,
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
showInfoContainer: {
marginBottom: 12,
},
showInfo: {
flexDirection: 'row',
marginBottom: 12,
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground,
borderRadius: 8,
padding: 8,
},
poster: {
width: 80,
height: 120,
borderRadius: 6,
},
showDetails: {
flex: 1,
marginLeft: 8,
justifyContent: 'center',
},
showTitle: {
fontSize: 18,
fontWeight: '800',
color: colors.white,
marginBottom: 2,
letterSpacing: 0.5,
},
showYear: {
fontSize: 13,
color: colors.lightGray,
marginBottom: 6,
},
episodeCountContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
episodeCount: {
fontSize: 12,
color: colors.lightGray,
},
ratingSection: {
backgroundColor: colors.darkBackground,
borderRadius: 8,
padding: 8,
marginBottom: 12,
},
ratingSourceContainer: {
marginBottom: 8,
},
ratingSourceTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.white,
marginBottom: 6,
letterSpacing: 0.5,
},
ratingSourceButtons: {
flexDirection: 'row',
gap: 8,
},
sourceButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
borderWidth: 1,
borderColor: colors.lightGray,
flex: 1,
alignItems: 'center',
},
sourceButtonActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
sourceButtonText: {
color: colors.lightGray,
fontSize: 14,
fontWeight: '600',
},
sourceButtonTextActive: {
color: colors.white,
fontWeight: '700',
},
tmdbDisclaimer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.black + '40',
padding: 6,
borderRadius: 6,
marginTop: 8,
gap: 6,
},
tmdbDisclaimerText: {
color: colors.lightGray,
fontSize: 12,
flex: 1,
lineHeight: 16,
},
legend: {
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground,
borderRadius: 8,
padding: 8,
marginBottom: 12,
},
legendTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.white,
marginBottom: 8,
letterSpacing: 0.5,
},
legendItems: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 12,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
minWidth: '45%',
marginBottom: 2,
},
legendColor: {
width: 14,
height: 14,
borderRadius: 3,
marginRight: 6,
},
legendText: {
color: colors.lightGray,
fontSize: 12,
},
warningLegends: {
marginTop: 8,
gap: 6,
borderTopWidth: 1,
borderTopColor: colors.black + '40',
paddingTop: 8,
},
warningLegend: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
warningText: {
color: colors.lightGray,
fontSize: 11,
flex: 1,
},
ratingsGrid: {
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground,
borderRadius: 8,
padding: 8,
},
gridContainer: {
flexDirection: 'row',
},
fixedColumn: {
width: 40,
borderRightWidth: 1,
borderRightColor: colors.black + '40',
},
seasonsScrollView: {
flex: 1,
},
gridHeader: {
flexDirection: 'row',
marginBottom: 8,
borderBottomWidth: 1,
borderBottomColor: colors.black + '40',
paddingBottom: 6,
paddingLeft: 6,
},
gridRow: {
flexDirection: 'row',
marginBottom: 6,
paddingLeft: 6,
},
episodeCell: {
height: 28,
justifyContent: 'center',
paddingRight: 6,
},
episodeColumn: {
height: 28,
justifyContent: 'center',
marginBottom: 8,
paddingRight: 6,
},
ratingColumn: {
width: 40,
alignItems: 'center',
},
headerText: {
color: colors.white,
fontWeight: '700',
fontSize: 12,
letterSpacing: 0.5,
},
episodeText: {
color: colors.lightGray,
fontSize: 12,
fontWeight: '500',
},
ratingCell: {
width: 32,
height: 24,
borderRadius: 3,
justifyContent: 'center',
alignItems: 'center',
},
ratingText: {
color: colors.white,
fontSize: 12,
fontWeight: '700',
},
ratingCellContainer: {
position: 'relative',
width: 32,
height: 24,
},
warningIcon: {
position: 'absolute',
top: -4,
right: -4,
backgroundColor: colors.black,
borderRadius: 8,
padding: 1,
},
loadingColumn: {
width: 40,
justifyContent: 'center',
alignItems: 'center',
opacity: 0.7,
},
loadingProgressContainer: {
alignItems: 'center',
gap: 4,
},
loadingProgressText: {
color: colors.primary,
fontSize: 10,
fontWeight: '600',
},
});
export default memo(ShowRatingsScreen, (prevProps, nextProps) => {
return prevProps.route.params.showId === nextProps.route.params.showId;
});