diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 4b5b061a8..3a6b3e428 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -7,7 +7,7 @@ import { FlashList, FlashListRef } from '@shopify/flash-list'; import { useTheme } from '../../contexts/ThemeContext'; import { useSettings } from '../../hooks/useSettings'; import { Episode } from '../../types/metadata'; -import { tmdbService } from '../../services/tmdbService'; +import { tmdbService, IMDbRatings } from '../../services/tmdbService'; import { storageService } from '../../services/storageService'; import { useFocusEffect } from '@react-navigation/native'; import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated'; @@ -37,6 +37,7 @@ interface SeriesContentProps { const DEFAULT_PLACEHOLDER = 'https://via.placeholder.com/300x450/1a1a1a/666666?text=No+Image'; const EPISODE_PLACEHOLDER = 'https://via.placeholder.com/500x280/1a1a1a/666666?text=No+Preview'; const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; +const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png'; export const SeriesContent: React.FC = ({ episodes, @@ -169,6 +170,8 @@ export const SeriesContent: React.FC = ({ const [enableItemAnimations, setEnableItemAnimations] = useState(false); // Local TMDB hydration for rating/runtime when addon (Cinemeta) lacks these const [tmdbEpisodeOverrides, setTmdbEpisodeOverrides] = useState<{ [epKey: string]: { vote_average?: number; runtime?: number; still_path?: string } }>({}); + // IMDb ratings for episodes - using a map for O(1) lookups instead of array searches + const [imdbRatingsMap, setImdbRatingsMap] = useState<{ [key: string]: number }>({}); // Add state for season view mode (persists for current show across navigation) const [seasonViewMode, setSeasonViewMode] = useState<'posters' | 'text'>('posters'); @@ -314,14 +317,13 @@ export const SeriesContent: React.FC = ({ // Scroll to the most recently watched episode if found if (mostRecentEpisodeIndex >= 0) { - const cardWidth = isTablet ? width * 0.4 + 16 : width * 0.85 + 16; - const scrollPosition = mostRecentEpisodeIndex * cardWidth; - setTimeout(() => { if (horizontalEpisodeScrollViewRef.current) { - horizontalEpisodeScrollViewRef.current.scrollToOffset({ - offset: scrollPosition, - animated: true + // Use scrollToIndex which automatically uses getItemLayout for accurate positioning + horizontalEpisodeScrollViewRef.current.scrollToIndex({ + index: mostRecentEpisodeIndex, + animated: true, + viewPosition: 0 // Align to start of card for precise positioning }); } }, 500); // Delay to ensure the season has loaded @@ -333,6 +335,68 @@ export const SeriesContent: React.FC = ({ loadEpisodesProgress(); }, [episodes, metadata?.id]); + // Fetch IMDb ratings for the show + useEffect(() => { + const fetchIMDbRatings = async () => { + try { + if (!metadata?.id) { + logger.log('[SeriesContent] No metadata.id, skipping IMDb ratings fetch'); + return; + } + + logger.log('[SeriesContent] Starting IMDb ratings fetch for metadata.id:', metadata.id); + + // Resolve TMDB show id + let tmdbShowId: number | null = null; + if (metadata.id.startsWith('tmdb:')) { + tmdbShowId = parseInt(metadata.id.split(':')[1], 10); + logger.log('[SeriesContent] Extracted TMDB ID from metadata.id:', tmdbShowId); + } else if (metadata.id.startsWith('tt')) { + logger.log('[SeriesContent] Found IMDb ID, looking up TMDB ID...'); + tmdbShowId = await tmdbService.findTMDBIdByIMDB(metadata.id); + logger.log('[SeriesContent] TMDB ID lookup result:', tmdbShowId); + } else { + logger.log('[SeriesContent] metadata.id does not start with tmdb: or tt:', metadata.id); + } + + if (!tmdbShowId) { + logger.warn('[SeriesContent] Could not resolve TMDB show ID, skipping IMDb ratings fetch'); + return; + } + + logger.log('[SeriesContent] Fetching IMDb ratings for TMDB ID:', tmdbShowId); + // Fetch IMDb ratings for all seasons + const ratings = await tmdbService.getIMDbRatings(tmdbShowId); + + if (ratings) { + logger.log('[SeriesContent] IMDb ratings fetched successfully. Seasons:', ratings.length); + + // Create a lookup map for O(1) access: key format "season:episode" -> rating + const ratingsMap: { [key: string]: number } = {}; + ratings.forEach(season => { + if (season.episodes) { + season.episodes.forEach(episode => { + const key = `${episode.season_number}:${episode.episode_number}`; + if (episode.vote_average) { + ratingsMap[key] = episode.vote_average; + } + }); + } + }); + + logger.log('[SeriesContent] IMDb ratings map created with', Object.keys(ratingsMap).length, 'episodes'); + setImdbRatingsMap(ratingsMap); + } else { + logger.warn('[SeriesContent] IMDb ratings fetch returned null/undefined'); + } + } catch (err) { + logger.error('[SeriesContent] Failed to fetch IMDb ratings:', err); + } + }; + + fetchIMDbRatings(); + }, [metadata?.id]); + // Hydrate TMDB rating/runtime for current season episodes if missing useEffect(() => { const hydrateFromTmdb = async () => { @@ -653,6 +717,13 @@ export const SeriesContent: React.FC = ({ ); }; + // Helper function to get IMDb rating for an episode - O(1) lookup using map + const getIMDbRating = useCallback((seasonNumber: number, episodeNumber: number): number | null => { + const key = `${seasonNumber}:${episodeNumber}`; + const rating = imdbRatingsMap[key]; + return rating ?? null; + }, [imdbRatingsMap]); + // Vertical layout episode card (traditional) const renderVerticalEpisodeCard = (episode: Episode) => { // Resolve episode image with addon-first logic @@ -708,7 +779,14 @@ export const SeriesContent: React.FC = ({ // Get episode progress const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`; const tmdbOverride = tmdbEpisodeOverrides[`${metadata?.id}:${episode.season_number}:${episode.episode_number}`]; - const effectiveVote = (tmdbOverride?.vote_average ?? episode.vote_average) || 0; + // Prioritize IMDb rating, fallback to TMDB + const imdbRating = getIMDbRating(episode.season_number, episode.episode_number); + const tmdbRating = tmdbOverride?.vote_average ?? episode.vote_average; + const effectiveVote = imdbRating ?? tmdbRating ?? 0; + const isImdbRating = imdbRating !== null; + + logger.log(`[SeriesContent] Vertical card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`); + const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime; if (!episode.still_path && tmdbOverride?.still_path) { const tmdbUrl = tmdbService.getImageUrl(tmdbOverride.still_path, 'original'); @@ -830,34 +908,10 @@ export const SeriesContent: React.FC = ({ - {effectiveVote > 0 && ( - - - - {effectiveVote.toFixed(1)} - - - )} {effectiveRuntime && ( @@ -872,6 +926,58 @@ export const SeriesContent: React.FC = ({ )} + {effectiveVote > 0 && ( + + {isImdbRating ? ( + <> + + + {effectiveVote.toFixed(1)} + + + ) : ( + <> + + + {effectiveVote.toFixed(1)} + + + )} + + )} {episode.air_date && ( = ({ // Get episode progress const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`; + const tmdbOverride = tmdbEpisodeOverrides[`${metadata?.id}:${episode.season_number}:${episode.episode_number}`]; + // Prioritize IMDb rating, fallback to TMDB + const imdbRating = getIMDbRating(episode.season_number, episode.episode_number); + const tmdbRating = tmdbOverride?.vote_average ?? episode.vote_average; + const effectiveVote = imdbRating ?? tmdbRating ?? 0; + const isImdbRating = imdbRating !== null; + const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime; + + logger.log(`[SeriesContent] Horizontal card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + }; + const progress = episodeProgress[episodeId]; const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; @@ -1049,36 +1174,77 @@ export const SeriesContent: React.FC = ({ - {episode.runtime && ( + {effectiveRuntime && ( - - {formatRuntime(episode.runtime)} - - - )} - {episode.vote_average > 0 && ( - - + - {episode.vote_average.toFixed(1)} + {formatRuntime(effectiveRuntime)} )} + {effectiveVote > 0 && ( + + {isImdbRating ? ( + <> + + + {effectiveVote.toFixed(1)} + + + ) : ( + <> + + + {effectiveVote.toFixed(1)} + + + )} + + )} + {episode.air_date && ( + + {formatDate(episode.air_date)} + + )} @@ -1207,14 +1373,31 @@ export const SeriesContent: React.FC = ({ initialNumToRender={3} maxToRenderPerBatch={5} windowSize={5} + snapToInterval={horizontalCardWidth + horizontalItemSpacing} + snapToAlignment="start" + decelerationRate="fast" getItemLayout={(data, index) => { const length = horizontalCardWidth + horizontalItemSpacing; return { length, - offset: length * index, + offset: horizontalPadding + (length * index), // Account for left padding index, }; }} + onScrollToIndexFailed={(info) => { + // Fallback if scrollToIndex fails - use scrollToOffset with calculated position + const wait = new Promise(resolve => setTimeout(resolve, 500)); + wait.then(() => { + if (horizontalEpisodeScrollViewRef.current) { + const length = horizontalCardWidth + horizontalItemSpacing; + const offset = horizontalPadding + (length * info.index); + horizontalEpisodeScrollViewRef.current.scrollToOffset({ + offset: offset, + animated: true + }); + } + }); + }} /> ) : ( // Vertical Layout (Traditional) - Using FlashList @@ -1382,6 +1565,10 @@ const styles = StyleSheet.create({ width: 20, height: 14, }, + imdbLogo: { + width: 35, + height: 18, + }, ratingText: { color: '#01b4e4', fontSize: 13, @@ -1549,6 +1736,7 @@ const styles = StyleSheet.create({ runtimeContainerHorizontal: { flexDirection: 'row', alignItems: 'center', + gap: 4, // chip background removed }, runtimeTextHorizontal: { @@ -1556,12 +1744,21 @@ const styles = StyleSheet.create({ fontSize: 11, fontWeight: '500', }, + airDateTextHorizontal: { + color: 'rgba(255,255,255,0.8)', + fontSize: 11, + opacity: 0.8, + }, ratingContainerHorizontal: { flexDirection: 'row', alignItems: 'center', // chip background removed gap: 2, }, + imdbLogoHorizontal: { + width: 35, + height: 18, + }, ratingTextHorizontal: { color: '#FFD700', fontSize: 11, diff --git a/src/screens/ShowRatingsScreen.tsx b/src/screens/ShowRatingsScreen.tsx index ac3e2de24..3da3cc92a 100644 --- a/src/screens/ShowRatingsScreen.tsx +++ b/src/screens/ShowRatingsScreen.tsx @@ -98,23 +98,7 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, getIMDbRating } }, [ratingSource, getTVMazeRating, getIMDbRating]); - const isRatingPotentiallyInaccurate = useCallback((episode: TMDBEpisode): boolean => { - const rating = getRatingForSource(episode); - if (!rating) return false; - - if (ratingSource === 'tmdb') { - const imdbRating = getIMDbRating(episode.season_number, episode.episode_number); - if (imdbRating) { - const difference = Math.abs(rating - imdbRating); - return difference >= 2; - } - } - - return false; - }, [getRatingForSource, ratingSource, getIMDbRating]); - const rating = getRatingForSource(episode); - const isInaccurate = isRatingPotentiallyInaccurate(episode); if (!rating) { if (!episode.air_date || new Date(episode.air_date) > new Date()) { @@ -141,14 +125,6 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, getIMDbRating ]}> {rating.toFixed(1)} - {isInaccurate && ( - - )} ); }); @@ -465,12 +441,6 @@ const ShowRatingsScreen = ({ route }: Props) => { ))} - - - - Rating differs significantly from IMDb - - diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index e25d22a93..aeda36b92 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -38,7 +38,7 @@ import { useMetadataAssets } from '../hooks/useMetadataAssets'; import { useTheme } from '../contexts/ThemeContext'; import { useTrailer } from '../contexts/TrailerContext'; import { Stream } from '../types/metadata'; -import { tmdbService } from '../services/tmdbService'; +import { tmdbService, IMDbRatings } from '../services/tmdbService'; import { stremioService } from '../services/stremioService'; import { localScraperService } from '../services/localScraperService'; import { VideoPlayerService } from '../services/videoPlayerService'; @@ -73,6 +73,7 @@ if (Platform.OS === 'android') { } const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; +const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png'; const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png'; const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_Vision_%28logo%29.svg/512px-Dolby_Vision_%28logo%29.svg.png?20220908042900'; @@ -717,6 +718,8 @@ export const StreamsScreen = () => { // TMDB hydration for series hero (rating/runtime/still) const [tmdbEpisodeOverride, setTmdbEpisodeOverride] = useState<{ vote_average?: number; runtime?: number; still_path?: string } | null>(null); + // IMDb ratings for episodes - using a map for O(1) lookups + const [imdbRatingsMap, setImdbRatingsMap] = useState<{ [key: string]: number }>({}); useEffect(() => { const hydrateEpisodeFromTmdb = async () => { @@ -755,6 +758,49 @@ export const StreamsScreen = () => { hydrateEpisodeFromTmdb(); }, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]); + // Fetch IMDb ratings for the show + useEffect(() => { + const fetchIMDbRatings = async () => { + try { + if (type !== 'series' && type !== 'other') return; + if (!id || !currentEpisode) return; + + // Resolve TMDB show id + let tmdbShowId: number | null = null; + if (id.startsWith('tmdb:')) { + tmdbShowId = parseInt(id.split(':')[1], 10); + } else if (id.startsWith('tt')) { + tmdbShowId = await tmdbService.findTMDBIdByIMDB(id); + } + if (!tmdbShowId) return; + + // Fetch IMDb ratings for all seasons + const ratings = await tmdbService.getIMDbRatings(tmdbShowId); + + if (ratings) { + // Create a lookup map for O(1) access: key format "season:episode" -> rating + const ratingsMap: { [key: string]: number } = {}; + ratings.forEach(season => { + if (season.episodes) { + season.episodes.forEach(episode => { + const key = `${episode.season_number}:${episode.episode_number}`; + if (episode.vote_average) { + ratingsMap[key] = episode.vote_average; + } + }); + } + }); + + setImdbRatingsMap(ratingsMap); + } + } catch (err) { + logger.error('[StreamsScreen] Failed to fetch IMDb ratings:', err); + } + }; + + fetchIMDbRatings(); + }, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]); + const navigateToPlayer = useCallback(async (stream: Stream, options?: { forceVlc?: boolean; headers?: Record }) => { // Filter headers for Vidrock - only send essential headers const filterHeadersForVidrock = (headers: Record | undefined): Record | undefined => { @@ -1555,12 +1601,33 @@ export const StreamsScreen = () => { return null; }, [currentEpisode, metadata, episodeThumbnail, tmdbEpisodeOverride?.still_path, settings.enrichMetadataWithTMDB]); - // Effective TMDB fields for hero (series) + // Helper function to get IMDb rating for an episode - O(1) lookup using map + const getIMDbRating = useCallback((seasonNumber: number, episodeNumber: number): number | null => { + const key = `${seasonNumber}:${episodeNumber}`; + const rating = imdbRatingsMap[key]; + return rating ?? null; + }, [imdbRatingsMap]); + + // Effective rating for hero (series) - prioritize IMDb, fallback to TMDB const effectiveEpisodeVote = useMemo(() => { if (!currentEpisode) return 0; + + // Try IMDb rating first + const imdbRating = getIMDbRating(currentEpisode.season_number, currentEpisode.episode_number); + if (imdbRating !== null) { + return imdbRating; + } + + // Fallback to TMDB const v = (tmdbEpisodeOverride?.vote_average ?? currentEpisode.vote_average) || 0; return typeof v === 'number' ? v : Number(v) || 0; - }, [currentEpisode, tmdbEpisodeOverride?.vote_average]); + }, [currentEpisode, tmdbEpisodeOverride?.vote_average, getIMDbRating]); + + // Check if current episode has IMDb rating + const hasIMDbRating = useMemo(() => { + if (!currentEpisode) return false; + return getIMDbRating(currentEpisode.season_number, currentEpisode.episode_number) !== null; + }, [currentEpisode, getIMDbRating]); const effectiveEpisodeRuntime = useMemo(() => { if (!currentEpisode) return undefined as number | undefined; @@ -1919,10 +1986,21 @@ export const StreamsScreen = () => { {effectiveEpisodeVote > 0 && ( - - - {effectiveEpisodeVote.toFixed(1)} - + {hasIMDbRating ? ( + <> + + + {effectiveEpisodeVote.toFixed(1)} + + + ) : ( + <> + + + {effectiveEpisodeVote.toFixed(1)} + + + )} )} {!!effectiveEpisodeRuntime && ( @@ -2483,6 +2561,10 @@ const createStyles = (colors: any) => StyleSheet.create({ width: 20, height: 14, }, + imdbLogo: { + width: 28, + height: 15, + }, streamsHeroRatingText: { color: colors.highEmphasis, fontSize: 13,