mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
1129 lines
No EOL
41 KiB
TypeScript
1129 lines
No EOL
41 KiB
TypeScript
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}
|
||
/>
|
||
)}
|
||
|
||
{/* 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} />
|
||
)}
|
||
</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: 14,
|
||
fontWeight: '700',
|
||
color: '#fff',
|
||
marginBottom: 8,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 1,
|
||
opacity: 0.8,
|
||
},
|
||
});
|
||
|
||
|
||
|
||
export default MetadataScreen; |