NuvioStreaming/src/screens/MetadataScreen.tsx
2025-10-13 13:36:50 +05:30

1370 lines
No EOL
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useState, useEffect, useMemo, useRef, memo } from 'react';
import {
View,
Text,
StyleSheet,
StatusBar,
ActivityIndicator,
Dimensions,
TouchableOpacity,
InteractionManager,
BackHandler,
Platform,
Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { useTheme } from '../contexts/ThemeContext';
import { useMetadata } from '../hooks/useMetadata';
import { useDominantColor, preloadDominantColor } from '../hooks/useDominantColor';
import { CastSection } from '../components/metadata/CastSection';
import { CastDetailsModal } from '../components/metadata/CastDetailsModal';
import { SeriesContent } from '../components/metadata/SeriesContent';
import { MovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection } from '../components/metadata/RatingsSection';
import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
import { RouteParams, Episode } from '../types/metadata';
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolate,
useSharedValue,
withTiming,
runOnJS,
runOnUI,
Easing,
interpolateColor,
withSpring,
} from 'react-native-reanimated';
import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { useSettings } from '../hooks/useSettings';
import { MetadataLoadingScreen } from '../components/loading/MetadataLoadingScreen';
import { useTrailer } from '../contexts/TrailerContext';
import FastImage from '@d11/react-native-fast-image';
// Import our optimized components and hooks
import HeroSection from '../components/metadata/HeroSection';
import FloatingHeader from '../components/metadata/FloatingHeader';
import MetadataDetails from '../components/metadata/MetadataDetails';
import { useMetadataAnimations } from '../hooks/useMetadataAnimations';
import { useMetadataAssets } from '../hooks/useMetadataAssets';
import { useWatchProgress } from '../hooks/useWatchProgress';
import { TraktService, TraktPlaybackItem } from '../services/traktService';
import { tmdbService } from '../services/tmdbService';
import { catalogService } from '../services/catalogService';
const { height } = Dimensions.get('window');
// Memoized components for better performance
const MemoizedCastSection = memo(CastSection);
const MemoizedSeriesContent = memo(SeriesContent);
const MemoizedMovieContent = memo(MovieContent);
const MemoizedMoreLikeThisSection = memo(MoreLikeThisSection);
const MemoizedRatingsSection = memo(RatingsSection);
const MemoizedCommentsSection = memo(CommentsSection);
const MemoizedCastDetailsModal = memo(CastDetailsModal);
const MetadataScreen: React.FC = () => {
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { id, type, episodeId, addonId } = route.params;
// Log route parameters for debugging
React.useEffect(() => {
console.log('🔍 [MetadataScreen] Route params:', { id, type, episodeId, addonId });
}, [id, type, episodeId, addonId]);
// Consolidated hooks for better performance
const { settings } = useSettings();
const { currentTheme } = useTheme();
const { top: safeAreaTop } = useSafeAreaInsets();
const { pauseTrailer } = useTrailer();
// Optimized state management - reduced state variables
const [isContentReady, setIsContentReady] = useState(false);
const [showCastModal, setShowCastModal] = useState(false);
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [shouldLoadSecondaryData, setShouldLoadSecondaryData] = useState(false);
const [isScreenFocused, setIsScreenFocused] = useState(true);
// Source switching removed
const transitionOpacity = useSharedValue(1);
const interactionComplete = useRef(false);
// Comment bottom sheet state
const [commentBottomSheetVisible, setCommentBottomSheetVisible] = useState(false);
const [selectedComment, setSelectedComment] = useState<any>(null);
const [revealedSpoilers, setRevealedSpoilers] = useState<Set<string>>(new Set());
// Debug state changes
React.useEffect(() => {
console.log('MetadataScreen: commentBottomSheetVisible changed to:', commentBottomSheetVisible);
}, [commentBottomSheetVisible]);
React.useEffect(() => {
console.log('MetadataScreen: selectedComment changed to:', selectedComment?.id);
}, [selectedComment]);
const {
metadata,
loading,
error: metadataError,
cast,
loadingCast,
episodes,
selectedSeason,
loadingSeasons,
loadMetadata,
handleSeasonChange,
toggleLibrary,
inLibrary,
groupedEpisodes,
recommendations,
loadingRecommendations,
setMetadata,
imdbId,
tmdbId,
} = useMetadata({ id, type, addonId });
// Log useMetadata hook state changes for debugging
React.useEffect(() => {
console.log('🔍 [MetadataScreen] useMetadata state:', {
loading,
hasMetadata: !!metadata,
metadataId: metadata?.id,
metadataName: metadata?.name,
error: metadataError,
hasCast: cast.length > 0,
hasEpisodes: episodes.length > 0,
seasonsCount: Object.keys(groupedEpisodes).length,
imdbId,
tmdbId,
hasNetworks: !!(metadata as any)?.networks,
networksCount: metadata?.networks ? metadata.networks.length : 0
});
}, [loading, metadata, metadataError, cast.length, episodes.length, Object.keys(groupedEpisodes).length, imdbId, tmdbId]);
// Optimized hooks with memoization and conditional loading
const watchProgressData = useWatchProgress(id, Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series', episodeId, episodes);
const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
const animations = useMetadataAnimations(safeAreaTop, watchProgressData.watchProgress);
// Extract dominant color from hero image for dynamic background
const heroImageUri = useMemo(() => {
if (!settings.useDominantBackgroundColor) return null;
if (!metadata) return null;
return assetData.bannerImage || metadata.banner || metadata.poster || null;
}, [settings.useDominantBackgroundColor, metadata, assetData.bannerImage]);
// Preload color extraction as soon as we have the URI
useEffect(() => {
if (heroImageUri) {
InteractionManager.runAfterInteractions(() => {
preloadDominantColor(heroImageUri);
});
}
}, [heroImageUri]);
const { dominantColor, loading: colorLoading } = useDominantColor(heroImageUri);
// Create shared values for smooth color interpolation
const bgFromColor = useSharedValue(currentTheme.colors.darkBackground);
const bgToColor = useSharedValue(currentTheme.colors.darkBackground);
const bgProgress = useSharedValue(1);
// Update the shared value when dominant color changes
const hasAnimatedInitialColorRef = useRef(false);
useEffect(() => {
const base = currentTheme.colors.darkBackground;
const target = (settings.useDominantBackgroundColor && dominantColor && dominantColor !== '#1a1a1a' && dominantColor !== null)
? dominantColor
: base;
if (!hasAnimatedInitialColorRef.current) {
// Initial: animate from base to target smoothly
bgFromColor.value = base as any;
bgToColor.value = target as any;
bgProgress.value = 0;
bgProgress.value = withSpring(1, {
damping: 30,
stiffness: 90,
});
hasAnimatedInitialColorRef.current = true;
return;
}
// Subsequent updates: retarget smoothly from the current on-screen color
runOnUI(() => {
'worklet';
const current = interpolateColor(
bgProgress.value,
[0, 1],
[bgFromColor.value as any, bgToColor.value as any]
);
bgFromColor.value = current as any;
bgToColor.value = target as any;
bgProgress.value = 0;
bgProgress.value = withSpring(1, {
damping: 30,
stiffness: 90,
});
})();
}, [dominantColor, currentTheme.colors.darkBackground, settings.useDominantBackgroundColor]);
// Create an animated style for the background color
const animatedBackgroundStyle = useAnimatedStyle(() => {
const color = interpolateColor(
bgProgress.value,
[0, 1],
[bgFromColor.value as any, bgToColor.value as any]
);
return { backgroundColor: color as any };
});
// For compatibility with existing code, maintain the static value as well
const dynamicBackgroundColor = useMemo(() => {
if (settings.useDominantBackgroundColor && dominantColor && dominantColor !== '#1a1a1a' && dominantColor !== null && dominantColor !== currentTheme.colors.darkBackground) {
return dominantColor;
}
return currentTheme.colors.darkBackground;
}, [dominantColor, currentTheme.colors.darkBackground, settings.useDominantBackgroundColor]);
// Debug logging for color extraction timing
useEffect(() => {
if (__DEV__ && heroImageUri && dominantColor) {
if (__DEV__) console.log('[MetadataScreen] Dynamic background color:', {
dominantColor,
fallback: currentTheme.colors.darkBackground,
finalColor: dynamicBackgroundColor,
heroImageUri
});
}
}, [dominantColor, dynamicBackgroundColor, heroImageUri, currentTheme.colors.darkBackground]);
// Focus effect for performance optimization
useFocusEffect(
useCallback(() => {
setIsScreenFocused(true);
// Delay secondary data loading until interactions are complete
const timer = setTimeout(() => {
if (!interactionComplete.current) {
InteractionManager.runAfterInteractions(() => {
setShouldLoadSecondaryData(true);
interactionComplete.current = true;
});
}
}, 100);
return () => {
setIsScreenFocused(false);
clearTimeout(timer);
};
}, [])
);
// Handle back button press - close modal if open, otherwise navigate back
useFocusEffect(
useCallback(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showCastModal) {
setShowCastModal(false);
return true; // Prevent default back behavior
}
return false; // Allow default back behavior (navigate back)
});
return () => backHandler.remove();
}, [showCastModal])
);
// Optimize secondary data loading
useEffect(() => {
if (metadata && isScreenFocused && !shouldLoadSecondaryData) {
const timer = setTimeout(() => {
setShouldLoadSecondaryData(true);
}, 300);
return () => clearTimeout(timer);
}
}, [metadata, isScreenFocused, shouldLoadSecondaryData]);
// Optimized Trakt progress fetching - only when secondary data should load
const fetchTraktProgress = useCallback(async () => {
if (!shouldLoadSecondaryData || !metadata || !id) return;
try {
const traktService = TraktService.getInstance();
const isAuthenticated = await traktService.isAuthenticated();
if (!isAuthenticated) {
if (__DEV__) console.log(`[MetadataScreen] Not authenticated with Trakt`);
return;
}
// Get all playback progress from Trakt (cached)
const allProgress = await traktService.getPlaybackProgress();
if (allProgress.length === 0) return;
// Filter progress for current content
let relevantProgress: TraktPlaybackItem[] = [];
if (type === 'movie') {
relevantProgress = allProgress.filter(item =>
item.type === 'movie' &&
item.movie?.ids.imdb === id.replace('tt', '')
);
} else if (Object.keys(groupedEpisodes).length > 0) {
relevantProgress = allProgress.filter(item =>
item.type === 'episode' &&
item.show?.ids.imdb === id.replace('tt', '')
);
}
if (relevantProgress.length === 0) return;
// Log only essential progress information for performance
if (__DEV__) console.log(`[MetadataScreen] Found ${relevantProgress.length} Trakt progress items for ${type}`);
// Find most recent progress if multiple episodes
if (Object.keys(groupedEpisodes).length > 0 && relevantProgress.length > 1) {
const mostRecent = relevantProgress.sort((a, b) =>
new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime()
)[0];
if (mostRecent.episode && mostRecent.show) {
if (__DEV__) console.log(`[MetadataScreen] Most recent: S${mostRecent.episode.season}E${mostRecent.episode.number} - ${mostRecent.progress.toFixed(1)}%`);
}
}
} catch (error) {
if (__DEV__) console.error(`[MetadataScreen] Failed to fetch Trakt progress:`, error);
}
}, [shouldLoadSecondaryData, metadata, id, type]);
// Debounced Trakt progress fetching
useEffect(() => {
if (shouldLoadSecondaryData && metadata && id) {
const timer = setTimeout(fetchTraktProgress, 500);
return () => clearTimeout(timer);
}
}, [shouldLoadSecondaryData, metadata, id, fetchTraktProgress]);
// Memory management and cleanup
useEffect(() => {
return () => {
// Cleanup on unmount
if (transitionOpacity.value !== 0) {
transitionOpacity.value = 0;
}
// Reset secondary data loading state
setShouldLoadSecondaryData(false);
interactionComplete.current = false;
};
}, []);
// Performance monitoring (development only)
useEffect(() => {
if (__DEV__ && metadata) {
const startTime = Date.now();
const timer = setTimeout(() => {
const renderTime = Date.now() - startTime;
if (renderTime > 100) {
if (__DEV__) console.warn(`[MetadataScreen] Slow render detected: ${renderTime}ms for ${metadata.name}`);
}
}, 0);
return () => clearTimeout(timer);
}
}, [metadata]);
// Memory monitoring and cleanup
useEffect(() => {
if (__DEV__) {
const memoryMonitor = () => {
// Check if we have access to memory info
if (performance && (performance as any).memory) {
const memory = (performance as any).memory;
const usedMB = Math.round(memory.usedJSHeapSize / 1048576);
const totalMB = Math.round(memory.totalJSHeapSize / 1048576);
const limitMB = Math.round(memory.jsHeapSizeLimit / 1048576);
if (__DEV__) console.log(`[MetadataScreen] Memory usage: ${usedMB}MB / ${totalMB}MB (limit: ${limitMB}MB)`);
// Trigger cleanup if memory usage is high
if (usedMB > limitMB * 0.8) {
if (__DEV__) console.warn(`[MetadataScreen] High memory usage detected (${usedMB}MB), triggering cleanup`);
// Force garbage collection if available
if (global.gc) {
global.gc();
}
}
}
};
// Monitor memory every 10 seconds
const interval = setInterval(memoryMonitor, 10000);
return () => clearInterval(interval);
}
}, []);
// Memoized derived values for performance
const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]);
// Log readiness state for debugging
React.useEffect(() => {
console.log('🔍 [MetadataScreen] Readiness state:', {
isReady,
loading,
hasMetadata: !!metadata,
hasError: !!metadataError,
errorMessage: metadataError
});
}, [isReady, loading, metadata, metadataError]);
// Optimized content ready state management
useEffect(() => {
if (isReady && isScreenFocused) {
setIsContentReady(true);
transitionOpacity.value = withTiming(1, { duration: 50 });
} else if (!isReady && isContentReady) {
setIsContentReady(false);
transitionOpacity.value = 0;
}
}, [isReady, isContentReady, isScreenFocused]);
// Optimized callback functions with reduced dependencies and haptics throttling
const handleToggleLibrary = useCallback(() => {
if (isScreenFocused) {
Haptics.impactAsync(inLibrary ? Haptics.ImpactFeedbackStyle.Light : Haptics.ImpactFeedbackStyle.Medium);
}
toggleLibrary();
}, [inLibrary, toggleLibrary, isScreenFocused]);
const handleSeasonChangeWithHaptics = useCallback((seasonNumber: number) => {
if (isScreenFocused) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
handleSeasonChange(seasonNumber);
}, [handleSeasonChange, isScreenFocused]);
const handleShowStreams = useCallback(() => {
const { watchProgress } = watchProgressData;
// Ensure trailer stops immediately before navigating to Streams
try { pauseTrailer(); } catch {}
// Helper to build episodeId from episode object
const buildEpisodeId = (ep: any): string => {
return ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
};
if (Object.keys(groupedEpisodes).length > 0) {
// Determine if current episode is finished
let progressPercent = 0;
if (watchProgress && watchProgress.duration > 0) {
progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
}
let targetEpisodeId: string | undefined;
if (progressPercent >= 85 && watchProgress?.episodeId) {
// Try to navigate to next episode support multiple episodeId formats
let currentSeason: number | null = null;
let currentEpisode: number | null = null;
const parts = watchProgress.episodeId.split(':');
if (parts.length === 3) {
// showId:season:episode
currentSeason = parseInt(parts[1], 10);
currentEpisode = parseInt(parts[2], 10);
} else if (parts.length === 2) {
// season:episode
currentSeason = parseInt(parts[0], 10);
currentEpisode = parseInt(parts[1], 10);
} else {
// pattern like s5e01
const match = watchProgress.episodeId.match(/s(\d+)e(\d+)/i);
if (match) {
currentSeason = parseInt(match[1], 10);
currentEpisode = parseInt(match[2], 10);
}
}
if (currentSeason !== null && currentEpisode !== null) {
// DIRECT APPROACH: Just create the next episode ID directly
// This ensures we navigate to the next episode even if it's not yet in our episodes array
const nextEpisodeId = `${id}:${currentSeason}:${currentEpisode + 1}`;
if (__DEV__) console.log(`[MetadataScreen] Created next episode ID directly: ${nextEpisodeId}`);
// Still try to find the episode in our list to verify it exists
const nextEpisodeExists = episodes.some(ep =>
ep.season_number === currentSeason && ep.episode_number === (currentEpisode + 1)
);
if (nextEpisodeExists) {
if (__DEV__) console.log(`[MetadataScreen] Verified next episode S${currentSeason}E${currentEpisode + 1} exists in episodes list`);
} else {
if (__DEV__) console.log(`[MetadataScreen] Warning: Next episode S${currentSeason}E${currentEpisode + 1} not found in episodes list, but proceeding anyway`);
}
targetEpisodeId = nextEpisodeId;
}
}
// Fallback logic: if not finished or nextEp not found
if (!targetEpisodeId) {
targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? buildEpisodeId(episodes[0]) : undefined);
if (__DEV__) console.log(`[MetadataScreen] Using fallback episode ID: ${targetEpisodeId}`);
}
if (targetEpisodeId) {
// Ensure the episodeId has showId prefix (id:season:episode)
const epParts = targetEpisodeId.split(':');
let normalizedEpisodeId = targetEpisodeId;
if (epParts.length === 2) {
normalizedEpisodeId = `${id}:${epParts[0]}:${epParts[1]}`;
}
if (__DEV__) console.log(`[MetadataScreen] Navigating to streams with episodeId: ${normalizedEpisodeId}`);
navigation.navigate('Streams', { id, type, episodeId: normalizedEpisodeId });
return;
}
}
// Normalize fallback episodeId too
let fallbackEpisodeId = episodeId;
if (episodeId && episodeId.split(':').length === 2) {
const p = episodeId.split(':');
fallbackEpisodeId = `${id}:${p[0]}:${p[1]}`;
}
if (__DEV__) console.log(`[MetadataScreen] Navigating with fallback episodeId: ${fallbackEpisodeId}`);
navigation.navigate('Streams', { id, type, episodeId: fallbackEpisodeId });
}, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]);
const handleEpisodeSelect = useCallback((episode: Episode) => {
if (!isScreenFocused) return;
if (__DEV__) console.log('[MetadataScreen] Selected Episode:', episode.episode_number, episode.season_number);
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;
// Optimize navigation with requestAnimationFrame
requestAnimationFrame(() => {
// Ensure trailer stops immediately before navigating to Streams
try { pauseTrailer(); } catch {}
navigation.navigate('Streams', {
id,
type,
episodeId,
episodeThumbnail: episode.still_path || undefined
});
});
}, [navigation, id, type, isScreenFocused, pauseTrailer]);
const handleBack = useCallback(() => {
if (isScreenFocused) {
navigation.goBack();
}
}, [navigation, isScreenFocused]);
const handleSelectCastMember = useCallback((castMember: any) => {
if (!isScreenFocused) return;
setSelectedCastMember(castMember);
setShowCastModal(true);
}, [isScreenFocused]);
const handleCommentPress = useCallback((comment: any) => {
console.log('MetadataScreen: handleCommentPress called with comment:', comment?.id);
if (!isScreenFocused) {
console.log('MetadataScreen: Screen not focused, ignoring');
return;
}
console.log('MetadataScreen: Setting selected comment and opening bottomsheet');
setSelectedComment(comment);
setCommentBottomSheetVisible(true);
console.log('MetadataScreen: State should be updated now');
}, [isScreenFocused]);
const handleCommentBottomSheetClose = useCallback(() => {
setCommentBottomSheetVisible(false);
setSelectedComment(null);
}, []);
const handleSpoilerPress = useCallback((comment: any) => {
Alert.alert(
'Spoiler Warning',
'This comment contains spoilers. Are you sure you want to reveal it?',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Reveal Spoilers',
style: 'destructive',
onPress: () => {
setRevealedSpoilers(prev => new Set([...prev, comment.id.toString()]));
},
},
]
);
}, []);
// Source switching removed
// Ultra-optimized animated styles - minimal calculations with conditional updates
const containerStyle = useAnimatedStyle(() => ({
opacity: isScreenFocused ? animations.screenOpacity.value : 0.8,
}), [isScreenFocused]);
const contentStyle = useAnimatedStyle(() => ({
opacity: animations.contentOpacity.value,
transform: [{ translateY: animations.uiElementsTranslateY.value }]
}), []);
const transitionStyle = useAnimatedStyle(() => ({
opacity: transitionOpacity.value,
}), []);
// Improved error component with user-friendly messages and error codes
const ErrorComponent = useMemo(() => {
if (!metadataError) return null;
// Parse error to extract code and user-friendly message
const parseError = (error: string) => {
console.log('🔍 Parsing error in MetadataScreen:', error);
// Check for HTTP status codes - handle multiple formats
// Match patterns like: "status code 500", "status": 500, "Request failed with status code 500"
const statusCodeMatch = error.match(/status code (\d+)/) ||
error.match(/"status":\s*(\d+)/) ||
error.match(/Request failed with status code (\d+)/) ||
error.match(/\b(\d{3})\b/); // Match any 3-digit number (last resort)
if (statusCodeMatch) {
const code = parseInt(statusCodeMatch[1]);
console.log('✅ Found status code:', code);
switch (code) {
case 404:
return { code: '404', message: 'Content not found', userMessage: 'This content doesn\'t exist or may have been removed.' };
case 500:
return { code: '500', message: 'Server error', userMessage: 'The server is temporarily unavailable. Please try again later.' };
case 502:
return { code: '502', message: 'Bad gateway', userMessage: 'The server is experiencing issues. Please try again later.' };
case 503:
return { code: '503', message: 'Service unavailable', userMessage: 'The service is currently down for maintenance. Please try again later.' };
case 429:
return { code: '429', message: 'Too many requests', userMessage: 'You\'re making too many requests. Please wait a moment and try again.' };
case 408:
return { code: '408', message: 'Request timeout', userMessage: 'The request took too long. Please try again.' };
default:
return { code: code.toString(), message: `Error ${code}`, userMessage: 'Something went wrong. Please try again.' };
}
}
// Check for network/Axios errors
if (error.includes('Network Error') ||
error.includes('ERR_BAD_RESPONSE') ||
error.includes('Request failed') ||
error.includes('ERR_NETWORK')) {
return { code: 'NETWORK', message: 'Network error', userMessage: 'Please check your internet connection and try again.' };
}
// Check for timeout errors
if (error.includes('timeout') ||
error.includes('timed out') ||
error.includes('ECONNABORTED') ||
error.includes('ETIMEDOUT')) {
return { code: 'TIMEOUT', message: 'Request timeout', userMessage: 'The request took too long. Please try again.' };
}
// Check for authentication errors
if (error.includes('401') || error.includes('Unauthorized') || error.includes('authentication')) {
return { code: '401', message: 'Authentication error', userMessage: 'Please check your account settings and try again.' };
}
// Check for permission errors
if (error.includes('403') || error.includes('Forbidden') || error.includes('permission')) {
return { code: '403', message: 'Access denied', userMessage: 'You don\'t have permission to access this content.' };
}
// Check for "not found" errors - but only if no status code was found
if (!statusCodeMatch && (error.includes('Content not found') || error.includes('not found'))) {
return { code: '404', message: 'Content not found', userMessage: 'This content doesn\'t exist or may have been removed.' };
}
// Check for retry/attempt errors
if (error.includes('attempts') || error.includes('Please check your connection')) {
return { code: 'CONNECTION', message: 'Connection error', userMessage: 'Please check your internet connection and try again.' };
}
// Check for streams-related errors
if (error.includes('streams') || error.includes('Failed to load streams')) {
return { code: 'STREAMS', message: 'Streams unavailable', userMessage: 'Streaming sources are currently unavailable. Please try again later.' };
}
// Default case
return { code: 'UNKNOWN', message: 'Unknown error', userMessage: 'An unexpected error occurred. Please try again.' };
};
const errorInfo = parseError(metadataError);
return (
<SafeAreaView
style={[styles.container, { backgroundColor: dynamicBackgroundColor }]}
edges={['bottom']}
>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
<View style={styles.errorContainer}>
<MaterialIcons name="error-outline" size={64} color={currentTheme.colors.error || '#FF6B6B'} />
<Text style={[styles.errorTitle, { color: currentTheme.colors.highEmphasis }]}>
Unable to Load Content
</Text>
<Text style={[styles.errorCode, { color: currentTheme.colors.textMuted }]}>
Error Code: {errorInfo.code}
</Text>
<Text style={[styles.errorMessage, { color: currentTheme.colors.highEmphasis }]}>
{errorInfo.userMessage}
</Text>
{__DEV__ && (
<Text style={[styles.errorDetails, { color: currentTheme.colors.textMuted }]}>
{metadataError}
</Text>
)}
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={loadMetadata}
>
<MaterialIcons name="refresh" size={20} color={currentTheme.colors.white} style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.backButton, { borderColor: currentTheme.colors.primary }]}
onPress={handleBack}
>
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>Go Back</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}, [metadataError, currentTheme, loadMetadata, handleBack]);
// Show error if exists
if (metadataError || (!loading && !metadata)) {
console.log('🔍 [MetadataScreen] Showing error component:', {
hasError: !!metadataError,
errorMessage: metadataError,
isLoading: loading,
hasMetadata: !!metadata,
loadingState: loading
});
return ErrorComponent;
}
// Show loading screen if metadata is not yet available
if (loading || !isContentReady) {
console.log('🔍 [MetadataScreen] Showing loading screen:', {
isLoading: loading,
isContentReady,
hasMetadata: !!metadata,
errorMessage: metadataError
});
return <MetadataLoadingScreen type={Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series'} />;
}
return (
<Animated.View style={[animatedBackgroundStyle, { flex: 1 }]}>
<SafeAreaView
style={[containerStyle, styles.container]}
edges={['bottom']}
>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
{metadata && (
<>
{/* Floating Header - Optimized */}
<FloatingHeader
metadata={metadata}
logoLoadError={assetData.logoLoadError}
handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary}
headerElementsY={animations.headerElementsY}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
setLogoLoadError={assetData.setLogoLoadError}
/>
<Animated.ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
bounces={Platform.OS === 'ios'}
overScrollMode={Platform.OS === 'android' ? 'always' : 'always'}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.scrollContent}
>
{/* Hero Section - Optimized */}
<HeroSection
metadata={metadata}
bannerImage={assetData.bannerImage}
loadingBanner={assetData.loadingBanner}
logoLoadError={assetData.logoLoadError}
scrollY={animations.scrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
logoOpacity={animations.logoOpacity}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgressData.watchProgress}
type={Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series'}
getEpisodeDetails={watchProgressData.getEpisodeDetails}
handleShowStreams={handleShowStreams}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
id={id}
navigation={navigation}
getPlayButtonText={watchProgressData.getPlayButtonText}
setBannerImage={assetData.setBannerImage}
setLogoLoadError={assetData.setLogoLoadError}
groupedEpisodes={groupedEpisodes}
dynamicBackgroundColor={dynamicBackgroundColor}
handleBack={handleBack}
tmdbId={tmdbId}
/>
{/* Main Content - Optimized */}
<Animated.View style={contentStyle}>
<MetadataDetails
metadata={metadata}
imdbId={imdbId}
type={Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series'}
contentId={id}
loadingMetadata={false}
renderRatings={() => imdbId && shouldLoadSecondaryData ? (
<MemoizedRatingsSection imdbId={imdbId} type={Object.keys(groupedEpisodes).length > 0 ? 'show' : 'movie'} />
) : null}
/>
{/* Production info row — shown below description and above cast for series */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.networks && metadata.networks.length > 0 && (
<View style={styles.productionContainer}>
<Text style={styles.productionHeader}>Network</Text>
<View style={styles.productionRow}>
{metadata.networks.slice(0, 6).map((net) => (
<View key={String(net.id || net.name)} style={styles.productionChip}>
{net.logo ? (
<FastImage
source={{ uri: net.logo }}
style={styles.productionLogo}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<Text style={styles.productionText}>{net.name}</Text>
)}
</View>
))}
</View>
</View>
)}
{/* Cast Section with skeleton when loading - Lazy loaded */}
{shouldLoadSecondaryData && (
<MemoizedCastSection
cast={cast}
loadingCast={loadingCast}
onSelectCastMember={handleSelectCastMember}
isTmdbEnrichmentEnabled={settings.enrichMetadataWithTMDB}
/>
)}
{/* Production info row — shown after cast for movies */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.networks && metadata.networks.length > 0 && (
<View style={styles.productionContainer}>
<Text style={styles.productionHeader}>Production</Text>
<View style={styles.productionRow}>
{metadata.networks.slice(0, 6).map((net) => (
<View key={String(net.id || net.name)} style={styles.productionChip}>
{net.logo ? (
<FastImage
source={{ uri: net.logo }}
style={styles.productionLogo}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<Text style={styles.productionText}>{net.name}</Text>
)}
</View>
))}
</View>
</View>
)}
{/* Comments Section - Lazy loaded */}
{shouldLoadSecondaryData && imdbId && (
<MemoizedCommentsSection
imdbId={imdbId}
type={Object.keys(groupedEpisodes).length > 0 ? 'show' : 'movie'}
onCommentPress={handleCommentPress}
/>
)}
{/* Movie Details section - shown above recommendations for movies when TMDB enrichment is ON */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.movieDetails && (
<View style={styles.tvDetailsContainer}>
<Text style={styles.tvDetailsHeader}>Movie Details</Text>
{metadata.movieDetails.tagline && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Tagline</Text>
<Text style={[styles.tvDetailValue, { fontStyle: 'italic' }]}>
"{metadata.movieDetails.tagline}"
</Text>
</View>
)}
{metadata.movieDetails.status && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Status</Text>
<Text style={styles.tvDetailValue}>{metadata.movieDetails.status}</Text>
</View>
)}
{metadata.movieDetails.releaseDate && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Release Date</Text>
<Text style={styles.tvDetailValue}>
{new Date(metadata.movieDetails.releaseDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Text>
</View>
)}
{metadata.movieDetails.runtime && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Runtime</Text>
<Text style={styles.tvDetailValue}>
{Math.floor(metadata.movieDetails.runtime / 60)}h {metadata.movieDetails.runtime % 60}m
</Text>
</View>
)}
{metadata.movieDetails.budget && metadata.movieDetails.budget > 0 && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Budget</Text>
<Text style={styles.tvDetailValue}>
${metadata.movieDetails.budget.toLocaleString()}
</Text>
</View>
)}
{metadata.movieDetails.revenue && metadata.movieDetails.revenue > 0 && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Revenue</Text>
<Text style={styles.tvDetailValue}>
${metadata.movieDetails.revenue.toLocaleString()}
</Text>
</View>
)}
{metadata.movieDetails.originCountry && metadata.movieDetails.originCountry.length > 0 && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Origin Country</Text>
<Text style={styles.tvDetailValue}>{metadata.movieDetails.originCountry.join(', ')}</Text>
</View>
)}
{metadata.movieDetails.originalLanguage && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Original Language</Text>
<Text style={styles.tvDetailValue}>{metadata.movieDetails.originalLanguage.toUpperCase()}</Text>
</View>
)}
</View>
)}
{/* Backdrop Gallery section - shown after details for movies/TV when TMDB ID is available */}
{shouldLoadSecondaryData && metadata?.tmdbId && (
<View style={styles.backdropGalleryContainer}>
<TouchableOpacity
style={styles.backdropGalleryButton}
onPress={() => navigation.navigate('BackdropGallery' as any, {
tmdbId: metadata.tmdbId,
type: Object.keys(groupedEpisodes).length > 0 ? 'tv' : 'movie',
title: metadata.name || 'Gallery'
})}
>
<MaterialIcons name="photo-library" size={24} color="#fff" />
<Text style={styles.backdropGalleryText}>Backdrop Gallery</Text>
<MaterialIcons name="chevron-right" size={24} color="#fff" />
</TouchableOpacity>
</View>
)}
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
{type === 'movie' && shouldLoadSecondaryData && (
<MemoizedMoreLikeThisSection
recommendations={recommendations}
loadingRecommendations={loadingRecommendations}
/>
)}
{/* Series/Movie Content with episode skeleton when loading */}
{Object.keys(groupedEpisodes).length > 0 ? (
<MemoizedSeriesContent
episodes={Object.values(groupedEpisodes).flat()}
selectedSeason={selectedSeason}
loadingSeasons={loadingSeasons}
onSeasonChange={handleSeasonChangeWithHaptics}
onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes}
metadata={metadata || undefined}
/>
) : (
metadata && <MemoizedMovieContent metadata={metadata} />
)}
{/* TV Details section - shown after episodes for series when TMDB enrichment is ON */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.tvDetails && (
<View style={styles.tvDetailsContainer}>
<Text style={styles.tvDetailsHeader}>Show Details</Text>
{metadata.tvDetails.status && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Status</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.status}</Text>
</View>
)}
{metadata.tvDetails.firstAirDate && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>First Air Date</Text>
<Text style={styles.tvDetailValue}>
{new Date(metadata.tvDetails.firstAirDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Text>
</View>
)}
{metadata.tvDetails.lastAirDate && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Last Air Date</Text>
<Text style={styles.tvDetailValue}>
{new Date(metadata.tvDetails.lastAirDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Text>
</View>
)}
{metadata.tvDetails.numberOfSeasons && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Seasons</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.numberOfSeasons}</Text>
</View>
)}
{metadata.tvDetails.numberOfEpisodes && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Total Episodes</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.numberOfEpisodes}</Text>
</View>
)}
{metadata.tvDetails.episodeRunTime && metadata.tvDetails.episodeRunTime.length > 0 && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Episode Runtime</Text>
<Text style={styles.tvDetailValue}>
{metadata.tvDetails.episodeRunTime.join(' - ')} min
</Text>
</View>
)}
{metadata.tvDetails.originCountry && metadata.tvDetails.originCountry.length > 0 && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Origin Country</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.originCountry.join(', ')}</Text>
</View>
)}
{metadata.tvDetails.originalLanguage && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Original Language</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.originalLanguage.toUpperCase()}</Text>
</View>
)}
{metadata.tvDetails.createdBy && metadata.tvDetails.createdBy.length > 0 && (
<View style={styles.tvDetailRow}>
<Text style={styles.tvDetailLabel}>Created By</Text>
<Text style={styles.tvDetailValue}>
{metadata.tvDetails.createdBy.map(creator => creator.name).join(', ')}
</Text>
</View>
)}
</View>
)}
</Animated.View>
</Animated.ScrollView>
</>
)}
{/* Cast Details Modal - Memoized */}
{showCastModal && (
<MemoizedCastDetailsModal
visible={showCastModal}
onClose={() => setShowCastModal(false)}
castMember={selectedCastMember}
/>
)}
{/* Comment Bottom Sheet - Memoized */}
<CommentBottomSheet
comment={selectedComment}
visible={commentBottomSheetVisible}
onClose={handleCommentBottomSheetClose}
theme={currentTheme}
isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false}
onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)}
/>
</SafeAreaView>
</Animated.View>
);
};
// Optimized styles with minimal properties
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
errorTitle: {
fontSize: 20,
fontWeight: '700',
textAlign: 'center',
marginTop: 16,
marginBottom: 8,
},
errorCode: {
fontSize: 14,
textAlign: 'center',
marginBottom: 8,
fontFamily: 'monospace',
},
errorMessage: {
fontSize: 16,
textAlign: 'center',
marginBottom: 16,
lineHeight: 22,
},
errorDetails: {
fontSize: 12,
textAlign: 'center',
marginTop: 16,
padding: 8,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: 8,
fontFamily: 'monospace',
},
errorText: {
fontSize: 18,
textAlign: 'center',
marginTop: 16,
marginBottom: 24,
},
retryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 24,
marginBottom: 16,
},
retryButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
backButton: {
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 24,
borderWidth: 2,
},
backButtonText: {
fontSize: 16,
fontWeight: '600',
},
// Skeleton loading styles
skeletonSection: {
padding: 16,
marginBottom: 24,
},
skeletonTitle: {
width: 150,
height: 20,
borderRadius: 4,
marginBottom: 16,
},
skeletonCastRow: {
flexDirection: 'row',
gap: 12,
},
skeletonCastItem: {
width: 80,
height: 120,
borderRadius: 8,
},
skeletonRecommendationsRow: {
flexDirection: 'row',
gap: 12,
},
skeletonRecommendationItem: {
width: 120,
height: 180,
borderRadius: 8,
},
skeletonEpisodesContainer: {
gap: 12,
},
skeletonEpisodeItem: {
width: '100%',
height: 80,
borderRadius: 8,
marginBottom: 8,
},
productionContainer: {
paddingHorizontal: 16,
marginTop: 0,
marginBottom: 20,
},
productionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: 8,
},
productionChip: {
paddingVertical: 8,
paddingHorizontal: 12,
backgroundColor: 'rgba(245,245,245,0.9)',
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
alignItems: 'center',
justifyContent: 'center',
minHeight: 36,
},
productionLogo: {
width: 64,
height: 22,
},
productionText: {
color: '#333',
fontSize: 12,
fontWeight: '600',
opacity: 0.9,
},
productionHeader: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
marginBottom: 12,
textTransform: 'uppercase',
letterSpacing: 1,
opacity: 0.9,
},
tvDetailsContainer: {
paddingHorizontal: 16,
marginTop: 8,
marginBottom: 16,
},
tvDetailsHeader: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
marginBottom: 12,
textTransform: 'uppercase',
letterSpacing: 1,
opacity: 0.9,
},
tvDetailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
tvDetailLabel: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
opacity: 0.8,
},
tvDetailValue: {
fontSize: 14,
fontWeight: '500',
color: '#fff',
opacity: 0.9,
textAlign: 'right',
flex: 1,
},
backdropGalleryContainer: {
paddingHorizontal: 16,
marginTop: 16,
marginBottom: 16,
},
backdropGalleryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 20,
backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
},
backdropGalleryText: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: '#fff',
marginLeft: 12,
opacity: 0.9,
},
});
export default MetadataScreen;