mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-10 20:10:54 +00:00
UI changes
This commit is contained in:
parent
e033352752
commit
d971d9f71f
3 changed files with 339 additions and 90 deletions
|
|
@ -7,7 +7,7 @@ import { FlashList, FlashListRef } from '@shopify/flash-list';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { useSettings } from '../../hooks/useSettings';
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
import { Episode } from '../../types/metadata';
|
import { Episode } from '../../types/metadata';
|
||||||
import { tmdbService } from '../../services/tmdbService';
|
import { tmdbService, IMDbRatings } from '../../services/tmdbService';
|
||||||
import { storageService } from '../../services/storageService';
|
import { storageService } from '../../services/storageService';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
|
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 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 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 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<SeriesContentProps> = ({
|
export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
episodes,
|
episodes,
|
||||||
|
|
@ -169,6 +170,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
const [enableItemAnimations, setEnableItemAnimations] = useState(false);
|
const [enableItemAnimations, setEnableItemAnimations] = useState(false);
|
||||||
// Local TMDB hydration for rating/runtime when addon (Cinemeta) lacks these
|
// 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 } }>({});
|
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)
|
// Add state for season view mode (persists for current show across navigation)
|
||||||
const [seasonViewMode, setSeasonViewMode] = useState<'posters' | 'text'>('posters');
|
const [seasonViewMode, setSeasonViewMode] = useState<'posters' | 'text'>('posters');
|
||||||
|
|
@ -314,14 +317,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
|
|
||||||
// Scroll to the most recently watched episode if found
|
// Scroll to the most recently watched episode if found
|
||||||
if (mostRecentEpisodeIndex >= 0) {
|
if (mostRecentEpisodeIndex >= 0) {
|
||||||
const cardWidth = isTablet ? width * 0.4 + 16 : width * 0.85 + 16;
|
|
||||||
const scrollPosition = mostRecentEpisodeIndex * cardWidth;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (horizontalEpisodeScrollViewRef.current) {
|
if (horizontalEpisodeScrollViewRef.current) {
|
||||||
horizontalEpisodeScrollViewRef.current.scrollToOffset({
|
// Use scrollToIndex which automatically uses getItemLayout for accurate positioning
|
||||||
offset: scrollPosition,
|
horizontalEpisodeScrollViewRef.current.scrollToIndex({
|
||||||
animated: true
|
index: mostRecentEpisodeIndex,
|
||||||
|
animated: true,
|
||||||
|
viewPosition: 0 // Align to start of card for precise positioning
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 500); // Delay to ensure the season has loaded
|
}, 500); // Delay to ensure the season has loaded
|
||||||
|
|
@ -333,6 +335,68 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
loadEpisodesProgress();
|
loadEpisodesProgress();
|
||||||
}, [episodes, metadata?.id]);
|
}, [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
|
// Hydrate TMDB rating/runtime for current season episodes if missing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hydrateFromTmdb = async () => {
|
const hydrateFromTmdb = async () => {
|
||||||
|
|
@ -653,6 +717,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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)
|
// Vertical layout episode card (traditional)
|
||||||
const renderVerticalEpisodeCard = (episode: Episode) => {
|
const renderVerticalEpisodeCard = (episode: Episode) => {
|
||||||
// Resolve episode image with addon-first logic
|
// Resolve episode image with addon-first logic
|
||||||
|
|
@ -708,7 +779,14 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
// Get episode progress
|
// Get episode progress
|
||||||
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
|
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
|
||||||
const tmdbOverride = tmdbEpisodeOverrides[`${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;
|
const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime;
|
||||||
if (!episode.still_path && tmdbOverride?.still_path) {
|
if (!episode.still_path && tmdbOverride?.still_path) {
|
||||||
const tmdbUrl = tmdbService.getImageUrl(tmdbOverride.still_path, 'original');
|
const tmdbUrl = tmdbService.getImageUrl(tmdbOverride.still_path, 'original');
|
||||||
|
|
@ -830,34 +908,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.episodeMetadata,
|
styles.episodeMetadata,
|
||||||
{
|
{
|
||||||
gap: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 4,
|
gap: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
|
||||||
flexWrap: 'wrap'
|
flexWrap: 'wrap'
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
{effectiveVote > 0 && (
|
|
||||||
<View style={styles.ratingContainer}>
|
|
||||||
<FastImage
|
|
||||||
source={{ uri: TMDB_LOGO }}
|
|
||||||
style={[
|
|
||||||
styles.tmdbLogo,
|
|
||||||
{
|
|
||||||
width: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 20,
|
|
||||||
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
resizeMode={FastImage.resizeMode.contain}
|
|
||||||
/>
|
|
||||||
<Text style={[
|
|
||||||
styles.ratingText,
|
|
||||||
{
|
|
||||||
color: currentTheme.colors.textMuted,
|
|
||||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
{effectiveVote.toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{effectiveRuntime && (
|
{effectiveRuntime && (
|
||||||
<View style={styles.runtimeContainer}>
|
<View style={styles.runtimeContainer}>
|
||||||
<MaterialIcons name="schedule" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color={currentTheme.colors.textMuted} />
|
<MaterialIcons name="schedule" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color={currentTheme.colors.textMuted} />
|
||||||
|
|
@ -872,6 +926,58 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{effectiveVote > 0 && (
|
||||||
|
<View style={styles.ratingContainer}>
|
||||||
|
{isImdbRating ? (
|
||||||
|
<>
|
||||||
|
<FastImage
|
||||||
|
source={{ uri: IMDb_LOGO }}
|
||||||
|
style={[
|
||||||
|
styles.imdbLogo,
|
||||||
|
{
|
||||||
|
width: isTV ? 32 : isLargeTablet ? 30 : isTablet ? 28 : 28,
|
||||||
|
height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
resizeMode={FastImage.resizeMode.contain}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.ratingText,
|
||||||
|
{
|
||||||
|
color: '#F5C518',
|
||||||
|
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13,
|
||||||
|
fontWeight: '600'
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
{effectiveVote.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FastImage
|
||||||
|
source={{ uri: TMDB_LOGO }}
|
||||||
|
style={[
|
||||||
|
styles.tmdbLogo,
|
||||||
|
{
|
||||||
|
width: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 20,
|
||||||
|
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
resizeMode={FastImage.resizeMode.contain}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.ratingText,
|
||||||
|
{
|
||||||
|
color: currentTheme.colors.textMuted,
|
||||||
|
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
{effectiveVote.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{episode.air_date && (
|
{episode.air_date && (
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.airDateText,
|
styles.airDateText,
|
||||||
|
|
@ -942,6 +1048,25 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
|
|
||||||
// Get episode progress
|
// Get episode progress
|
||||||
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
|
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 progress = episodeProgress[episodeId];
|
||||||
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
|
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
|
||||||
|
|
||||||
|
|
@ -1049,36 +1174,77 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.episodeMetadataRowHorizontal,
|
styles.episodeMetadataRowHorizontal,
|
||||||
{
|
{
|
||||||
gap: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
|
gap: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
{episode.runtime && (
|
{effectiveRuntime && (
|
||||||
<View style={styles.runtimeContainerHorizontal}>
|
<View style={styles.runtimeContainerHorizontal}>
|
||||||
<Text style={[
|
<MaterialIcons name="schedule" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color={currentTheme.colors.mediumEmphasis} />
|
||||||
styles.runtimeTextHorizontal,
|
|
||||||
{
|
|
||||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
|
|
||||||
fontWeight: isTV ? '600' : isLargeTablet ? '500' : isTablet ? '500' : '500'
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
{formatRuntime(episode.runtime)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{episode.vote_average > 0 && (
|
|
||||||
<View style={styles.ratingContainerHorizontal}>
|
|
||||||
<MaterialIcons name="star" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color="#FFD700" />
|
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.ratingTextHorizontal,
|
styles.runtimeTextHorizontal,
|
||||||
{
|
{
|
||||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
|
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
|
||||||
fontWeight: isTV ? '600' : isLargeTablet ? '600' : isTablet ? '600' : '600'
|
fontWeight: isTV ? '600' : isLargeTablet ? '500' : isTablet ? '500' : '500',
|
||||||
|
color: currentTheme.colors.mediumEmphasis
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
{episode.vote_average.toFixed(1)}
|
{formatRuntime(effectiveRuntime)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{effectiveVote > 0 && (
|
||||||
|
<View style={styles.ratingContainerHorizontal}>
|
||||||
|
{isImdbRating ? (
|
||||||
|
<>
|
||||||
|
<FastImage
|
||||||
|
source={{ uri: IMDb_LOGO }}
|
||||||
|
style={[
|
||||||
|
styles.imdbLogoHorizontal,
|
||||||
|
{
|
||||||
|
width: isTV ? 32 : isLargeTablet ? 30 : isTablet ? 28 : 28,
|
||||||
|
height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
resizeMode={FastImage.resizeMode.contain}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.ratingTextHorizontal,
|
||||||
|
{
|
||||||
|
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
|
||||||
|
fontWeight: isTV ? '600' : isLargeTablet ? '600' : isTablet ? '600' : '600',
|
||||||
|
color: '#F5C518'
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
{effectiveVote.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MaterialIcons name="star" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color="#FFD700" />
|
||||||
|
<Text style={[
|
||||||
|
styles.ratingTextHorizontal,
|
||||||
|
{
|
||||||
|
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
|
||||||
|
fontWeight: isTV ? '600' : isLargeTablet ? '600' : isTablet ? '600' : '600'
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
{effectiveVote.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{episode.air_date && (
|
||||||
|
<Text style={[
|
||||||
|
styles.airDateTextHorizontal,
|
||||||
|
{
|
||||||
|
color: currentTheme.colors.mediumEmphasis,
|
||||||
|
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
{formatDate(episode.air_date)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -1207,14 +1373,31 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
initialNumToRender={3}
|
initialNumToRender={3}
|
||||||
maxToRenderPerBatch={5}
|
maxToRenderPerBatch={5}
|
||||||
windowSize={5}
|
windowSize={5}
|
||||||
|
snapToInterval={horizontalCardWidth + horizontalItemSpacing}
|
||||||
|
snapToAlignment="start"
|
||||||
|
decelerationRate="fast"
|
||||||
getItemLayout={(data, index) => {
|
getItemLayout={(data, index) => {
|
||||||
const length = horizontalCardWidth + horizontalItemSpacing;
|
const length = horizontalCardWidth + horizontalItemSpacing;
|
||||||
return {
|
return {
|
||||||
length,
|
length,
|
||||||
offset: length * index,
|
offset: horizontalPadding + (length * index), // Account for left padding
|
||||||
index,
|
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
|
// Vertical Layout (Traditional) - Using FlashList
|
||||||
|
|
@ -1382,6 +1565,10 @@ const styles = StyleSheet.create({
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 14,
|
height: 14,
|
||||||
},
|
},
|
||||||
|
imdbLogo: {
|
||||||
|
width: 35,
|
||||||
|
height: 18,
|
||||||
|
},
|
||||||
ratingText: {
|
ratingText: {
|
||||||
color: '#01b4e4',
|
color: '#01b4e4',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
@ -1549,6 +1736,7 @@ const styles = StyleSheet.create({
|
||||||
runtimeContainerHorizontal: {
|
runtimeContainerHorizontal: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
// chip background removed
|
// chip background removed
|
||||||
},
|
},
|
||||||
runtimeTextHorizontal: {
|
runtimeTextHorizontal: {
|
||||||
|
|
@ -1556,12 +1744,21 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
airDateTextHorizontal: {
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
fontSize: 11,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
ratingContainerHorizontal: {
|
ratingContainerHorizontal: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
// chip background removed
|
// chip background removed
|
||||||
gap: 2,
|
gap: 2,
|
||||||
},
|
},
|
||||||
|
imdbLogoHorizontal: {
|
||||||
|
width: 35,
|
||||||
|
height: 18,
|
||||||
|
},
|
||||||
ratingTextHorizontal: {
|
ratingTextHorizontal: {
|
||||||
color: '#FFD700',
|
color: '#FFD700',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|
|
||||||
|
|
@ -98,23 +98,7 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, getIMDbRating
|
||||||
}
|
}
|
||||||
}, [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 rating = getRatingForSource(episode);
|
||||||
const isInaccurate = isRatingPotentiallyInaccurate(episode);
|
|
||||||
|
|
||||||
if (!rating) {
|
if (!rating) {
|
||||||
if (!episode.air_date || new Date(episode.air_date) > new Date()) {
|
if (!episode.air_date || new Date(episode.air_date) > new Date()) {
|
||||||
|
|
@ -141,14 +125,6 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, getIMDbRating
|
||||||
]}>
|
]}>
|
||||||
<Text style={styles.ratingText}>{rating.toFixed(1)}</Text>
|
<Text style={styles.ratingText}>{rating.toFixed(1)}</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{isInaccurate && (
|
|
||||||
<MaterialIcons
|
|
||||||
name="warning"
|
|
||||||
size={12}
|
|
||||||
color={theme.colors.warning}
|
|
||||||
style={styles.warningIcon}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -465,12 +441,6 @@ const ShowRatingsScreen = ({ route }: Props) => {
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</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>
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ import { useMetadataAssets } from '../hooks/useMetadataAssets';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { useTrailer } from '../contexts/TrailerContext';
|
import { useTrailer } from '../contexts/TrailerContext';
|
||||||
import { Stream } from '../types/metadata';
|
import { Stream } from '../types/metadata';
|
||||||
import { tmdbService } from '../services/tmdbService';
|
import { tmdbService, IMDbRatings } from '../services/tmdbService';
|
||||||
import { stremioService } from '../services/stremioService';
|
import { stremioService } from '../services/stremioService';
|
||||||
import { localScraperService } from '../services/localScraperService';
|
import { localScraperService } from '../services/localScraperService';
|
||||||
import { VideoPlayerService } from '../services/videoPlayerService';
|
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 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 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';
|
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)
|
// TMDB hydration for series hero (rating/runtime/still)
|
||||||
const [tmdbEpisodeOverride, setTmdbEpisodeOverride] = useState<{ vote_average?: number; runtime?: number; still_path?: string } | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const hydrateEpisodeFromTmdb = async () => {
|
const hydrateEpisodeFromTmdb = async () => {
|
||||||
|
|
@ -755,6 +758,49 @@ export const StreamsScreen = () => {
|
||||||
hydrateEpisodeFromTmdb();
|
hydrateEpisodeFromTmdb();
|
||||||
}, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]);
|
}, [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<string, string> }) => {
|
const navigateToPlayer = useCallback(async (stream: Stream, options?: { forceVlc?: boolean; headers?: Record<string, string> }) => {
|
||||||
// Filter headers for Vidrock - only send essential headers
|
// Filter headers for Vidrock - only send essential headers
|
||||||
const filterHeadersForVidrock = (headers: Record<string, string> | undefined): Record<string, string> | undefined => {
|
const filterHeadersForVidrock = (headers: Record<string, string> | undefined): Record<string, string> | undefined => {
|
||||||
|
|
@ -1555,12 +1601,33 @@ export const StreamsScreen = () => {
|
||||||
return null;
|
return null;
|
||||||
}, [currentEpisode, metadata, episodeThumbnail, tmdbEpisodeOverride?.still_path, settings.enrichMetadataWithTMDB]);
|
}, [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(() => {
|
const effectiveEpisodeVote = useMemo(() => {
|
||||||
if (!currentEpisode) return 0;
|
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;
|
const v = (tmdbEpisodeOverride?.vote_average ?? currentEpisode.vote_average) || 0;
|
||||||
return typeof v === 'number' ? v : Number(v) || 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(() => {
|
const effectiveEpisodeRuntime = useMemo(() => {
|
||||||
if (!currentEpisode) return undefined as number | undefined;
|
if (!currentEpisode) return undefined as number | undefined;
|
||||||
|
|
@ -1919,10 +1986,21 @@ export const StreamsScreen = () => {
|
||||||
</Text>
|
</Text>
|
||||||
{effectiveEpisodeVote > 0 && (
|
{effectiveEpisodeVote > 0 && (
|
||||||
<View style={styles.streamsHeroRating}>
|
<View style={styles.streamsHeroRating}>
|
||||||
<FastImage source={{ uri: TMDB_LOGO }} style={styles.tmdbLogo} resizeMode={FastImage.resizeMode.contain} />
|
{hasIMDbRating ? (
|
||||||
<Text style={styles.streamsHeroRatingText}>
|
<>
|
||||||
{effectiveEpisodeVote.toFixed(1)}
|
<FastImage source={{ uri: IMDb_LOGO }} style={styles.imdbLogo} resizeMode={FastImage.resizeMode.contain} />
|
||||||
</Text>
|
<Text style={[styles.streamsHeroRatingText, { color: '#F5C518' }]}>
|
||||||
|
{effectiveEpisodeVote.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FastImage source={{ uri: TMDB_LOGO }} style={styles.tmdbLogo} resizeMode={FastImage.resizeMode.contain} />
|
||||||
|
<Text style={styles.streamsHeroRatingText}>
|
||||||
|
{effectiveEpisodeVote.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{!!effectiveEpisodeRuntime && (
|
{!!effectiveEpisodeRuntime && (
|
||||||
|
|
@ -2483,6 +2561,10 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 14,
|
height: 14,
|
||||||
},
|
},
|
||||||
|
imdbLogo: {
|
||||||
|
width: 28,
|
||||||
|
height: 15,
|
||||||
|
},
|
||||||
streamsHeroRatingText: {
|
streamsHeroRatingText: {
|
||||||
color: colors.highEmphasis,
|
color: colors.highEmphasis,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue