import React, { useCallback, useMemo, memo, useState, useEffect, useRef, useLayoutEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, FlatList, SectionList, Platform, ImageBackground, ScrollView, StatusBar, Alert, Dimensions, Linking, } from 'react-native'; import * as ScreenOrientation from 'expo-screen-orientation'; import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { Image } from 'expo-image'; import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator'; import { useMetadata } from '../hooks/useMetadata'; import { useTheme } from '../contexts/ThemeContext'; import { Stream } from '../types/metadata'; import { tmdbService } from '../services/tmdbService'; import { stremioService } from '../services/stremioService'; import { VideoPlayerService } from '../services/videoPlayerService'; import { useSettings } from '../hooks/useSettings'; import QualityBadge from '../components/metadata/QualityBadge'; import Animated, { FadeIn, FadeInDown, SlideInDown, withSpring, withTiming, useAnimatedStyle, useSharedValue, interpolate, Extrapolate, runOnJS, cancelAnimation, SharedValue, Layout } from 'react-native-reanimated'; import { logger } from '../utils/logger'; const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; 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 { width, height } = Dimensions.get('window'); // Extracted Components const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: { stream: Stream; onPress: () => void; index: number; isLoading?: boolean; statusMessage?: string; theme: any; }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); const quality = stream.title?.match(/(\d+)p/)?.[1] || null; const isHDR = stream.title?.toLowerCase().includes('hdr'); const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; const isDebrid = stream.behaviorHints?.cached; // Determine if this is a HDRezka stream const isHDRezka = stream.name === 'HDRezka'; // For HDRezka streams, the title contains the quality information const displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream'); const displayAddonName = isHDRezka ? '' : (stream.title || ''); // Animation delay based on index - stagger effect const enterDelay = 100 + (index * 50); return ( {displayTitle} {displayAddonName && displayAddonName !== displayTitle && ( {displayAddonName} )} {/* Show loading indicator if stream is loading */} {isLoading && ( {statusMessage || "Loading..."} )} {quality && quality >= "720" && ( )} {isDolby && ( )} {size && ( {size} )} {isDebrid && ( DEBRID )} {/* Special badge for HDRezka streams */} {isHDRezka && ( HDREZKA )} ); }; const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); return ( {text} ); }); const ProviderFilter = memo(({ selectedProvider, providers, onSelect, theme }: { selectedProvider: string; providers: Array<{ id: string; name: string; }>; onSelect: (id: string) => void; theme: any; }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => ( onSelect(item.id)} > {item.name} ), [selectedProvider, onSelect, styles]); return ( item.id} horizontal showsHorizontalScrollIndicator={false} style={styles.filterScroll} bounces={true} overScrollMode="never" decelerationRate="fast" initialNumToRender={5} maxToRenderPerBatch={3} windowSize={3} getItemLayout={(data, index) => ({ length: 100, // Approximate width of each item offset: 100 * index, index, })} /> ); }); export const StreamsScreen = () => { const route = useRoute>(); const navigation = useNavigation(); const { id, type, episodeId } = route.params; const { settings } = useSettings(); const { currentTheme } = useTheme(); const { colors } = currentTheme; // Add ref to prevent excessive updates const isMounted = useRef(true); const loadStartTimeRef = useRef(0); const hasDoneInitialLoadRef = useRef(false); // Add state for handling orientation transition const [isTransitioning, setIsTransitioning] = useState(false); // Add timing logs const [loadStartTime, setLoadStartTime] = useState(0); const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); // Prevent excessive re-renders by using this guard const guardedSetState = useCallback((setter: () => void) => { if (isMounted.current) { setter(); } }, []); const { metadata, episodes, groupedStreams, loadingStreams, episodeStreams, loadingEpisodeStreams, selectedEpisode, loadStreams, loadEpisodeStreams, setSelectedEpisode, groupedEpisodes, imdbId, } = useMetadata({ id, type }); // Create styles using current theme colors const styles = React.useMemo(() => createStyles(colors), [colors]); const [selectedProvider, setSelectedProvider] = React.useState('all'); const [availableProviders, setAvailableProviders] = React.useState>(new Set()); // Optimize animation values with cleanup const headerOpacity = useSharedValue(0); const heroScale = useSharedValue(0.95); const filterOpacity = useSharedValue(0); // Add state for provider loading status const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({}); // Add state for more detailed provider loading tracking const [providerStatus, setProviderStatus] = useState<{ [key: string]: { loading: boolean; success: boolean; error: boolean; message: string; timeStarted: number; timeCompleted: number; } }>({}); // Monitor streams loading start and completion - FIXED to prevent loops useEffect(() => { // Skip processing if component is unmounting if (!isMounted.current) return; const now = Date.now(); // Define all providers you expect to load. This could be dynamic. const expectedProviders = ['stremio', 'hdrezka']; // Prevent infinite rerendering by using refs if (loadingStreams || loadingEpisodeStreams) { // --- Stream Loading has STARTED or is IN PROGRESS --- // Only log once when loading starts if (loadStartTimeRef.current === 0) { logger.log("⏱️ Stream loading started or in progress..."); // Update ref directly to avoid render cycle loadStartTimeRef.current = now; // Also update state for components that need it setLoadStartTime(now); } // Only update these once per loading cycle if (!hasDoneInitialLoadRef.current) { hasDoneInitialLoadRef.current = true; // Use the guarded setState to prevent issues after unmount guardedSetState(() => setProviderLoadTimes({})); // Update provider status to loading for all expected providers guardedSetState(() => setProviderStatus(prevStatus => { const newStatus = { ...prevStatus }; expectedProviders.forEach(providerId => { // If not already marked as loading, or if it's a fresh cycle, set to loading if (!newStatus[providerId] || !newStatus[providerId].loading) { newStatus[providerId] = { loading: true, success: false, error: false, message: 'Loading...', timeStarted: (newStatus[providerId]?.loading && newStatus[providerId]?.timeStarted) ? newStatus[providerId].timeStarted : now, timeCompleted: 0, }; } }); return newStatus; })); // Update simple loading flag for all expected providers guardedSetState(() => setLoadingProviders(prevLoading => { const newLoading = { ...prevLoading }; expectedProviders.forEach(providerId => { newLoading[providerId] = true; }); return newLoading; })); } } else if (loadStartTimeRef.current > 0) { // --- Stream Loading has FINISHED --- logger.log("🏁 Stream loading finished. Processing results."); const currentStreamsData = type === 'series' ? episodeStreams : groupedStreams; // Find all providers that returned streams const providersWithStreams = Object.entries(currentStreamsData) .filter(([_, data]) => data.streams && data.streams.length > 0) .map(([providerId]) => providerId); logger.log(`📊 Providers with streams: ${providersWithStreams.join(', ')}`); // Reset refs for next load cycle loadStartTimeRef.current = 0; hasDoneInitialLoadRef.current = false; // Update states only if component is still mounted if (isMounted.current) { // Update simple loading flag: all expected providers are no longer loading guardedSetState(() => setLoadingProviders(prevLoading => { const newLoading = { ...prevLoading }; expectedProviders.forEach(providerId => { newLoading[providerId] = false; }); return newLoading; })); // Update detailed provider status based on results guardedSetState(() => setProviderStatus(prevStatus => { const newStatus = { ...prevStatus }; expectedProviders.forEach(providerId => { if (newStatus[providerId]) { // Ensure the provider entry exists const providerHasStreams = currentStreamsData[providerId] && currentStreamsData[providerId].streams && currentStreamsData[providerId].streams.length > 0; newStatus[providerId] = { ...newStatus[providerId], // Preserve timeStarted loading: false, success: providerHasStreams, // Mark error if it was loading and now no streams, and wasn't already successful error: !providerHasStreams && newStatus[providerId].loading && !newStatus[providerId].success, message: providerHasStreams ? 'Loaded successfully' : (newStatus[providerId].error ? 'Error or no streams' : 'No streams found'), timeCompleted: now, }; } }); return newStatus; })); // Update the set of available providers based on what actually loaded streams const providersWithStreamsSet = new Set(providersWithStreams); guardedSetState(() => setAvailableProviders(providersWithStreamsSet)); // Reset loadStartTime to signify the end of this loading cycle guardedSetState(() => setLoadStartTime(0)); } } }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type, guardedSetState]); // Add useEffect to update availableProviders whenever streams change useEffect(() => { if (!loadingStreams && !loadingEpisodeStreams) { const streams = type === 'series' ? episodeStreams : groupedStreams; // Only include providers that actually have streams const providers = new Set( Object.entries(streams) .filter(([_, data]) => data.streams && data.streams.length > 0) .map(([providerId]) => providerId) ); setAvailableProviders(providers); // Also reset the selected provider to 'all' if the current selection is no longer available if (selectedProvider !== 'all' && !providers.has(selectedProvider)) { setSelectedProvider('all'); } } }, [type, groupedStreams, episodeStreams, loadingStreams, loadingEpisodeStreams, selectedProvider]); React.useEffect(() => { if (type === 'series' && episodeId) { logger.log(`🎬 Loading episode streams for: ${episodeId}`); setLoadingProviders({ 'stremio': true, 'hdrezka': true }); setSelectedEpisode(episodeId); loadEpisodeStreams(episodeId); } else if (type === 'movie') { logger.log(`🎬 Loading movie streams for: ${id}`); // setLoadingProviders({ // This is now handled by the main effect // 'stremio': true, // 'hdrezka': true // }); loadStreams(); } }, [type, episodeId]); React.useEffect(() => { // Trigger entrance animations headerOpacity.value = withTiming(1, { duration: 400 }); heroScale.value = withSpring(1, { damping: 15, stiffness: 100, mass: 0.9, restDisplacementThreshold: 0.01 }); filterOpacity.value = withTiming(1, { duration: 500 }); return () => { // Cleanup animations on unmount cancelAnimation(headerOpacity); cancelAnimation(heroScale); cancelAnimation(filterOpacity); }; }, []); // Memoize handlers const handleBack = useCallback(() => { const cleanup = () => { headerOpacity.value = withTiming(0, { duration: 200 }); heroScale.value = withTiming(0.95, { duration: 200 }); filterOpacity.value = withTiming(0, { duration: 200 }); }; cleanup(); // For series episodes, always replace current screen with metadata screen if (type === 'series') { navigation.replace('Metadata', { id: id, type: type }); } else { navigation.goBack(); } }, [navigation, headerOpacity, heroScale, filterOpacity, type, id]); const handleProviderChange = useCallback((provider: string) => { setSelectedProvider(provider); }, []); const currentEpisode = useMemo(() => { if (!selectedEpisode) return null; // Search through all episodes in all seasons const allEpisodes = Object.values(groupedEpisodes).flat(); return allEpisodes.find(ep => ep.stremioId === selectedEpisode || `${id}:${ep.season_number}:${ep.episode_number}` === selectedEpisode ); }, [selectedEpisode, groupedEpisodes, id]); const navigateToPlayer = useCallback(async (stream: Stream) => { try { // Lock orientation to landscape before navigation to prevent glitches await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); // Small delay to ensure orientation is set before navigation await new Promise(resolve => setTimeout(resolve, 100)); // Prepare available streams for the change source feature const streamsToPass = type === 'series' ? episodeStreams : groupedStreams; // Determine the stream name using the same logic as StreamCard const isHDRezka = stream.name === 'HDRezka'; const streamName = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream'); navigation.navigate('Player', { uri: stream.url, title: metadata?.name || '', episodeTitle: type === 'series' ? currentEpisode?.name : undefined, season: type === 'series' ? currentEpisode?.season_number : undefined, episode: type === 'series' ? currentEpisode?.episode_number : undefined, quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, year: metadata?.year, streamProvider: stream.name, streamName: streamName, id, type, episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, imdbId: imdbId || undefined, availableStreams: streamsToPass, }); } catch (error) { logger.error('[StreamsScreen] Error locking orientation before navigation:', error); // Fallback: navigate anyway const streamsToPass = type === 'series' ? episodeStreams : groupedStreams; navigation.navigate('Player', { uri: stream.url, title: metadata?.name || '', episodeTitle: type === 'series' ? currentEpisode?.name : undefined, season: type === 'series' ? currentEpisode?.season_number : undefined, episode: type === 'series' ? currentEpisode?.episode_number : undefined, quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, year: metadata?.year, streamProvider: stream.name, id, type, episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, imdbId: imdbId || undefined, availableStreams: streamsToPass, }); } }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams]); // Update handleStreamPress const handleStreamPress = useCallback(async (stream: Stream) => { try { if (stream.url) { logger.log('handleStreamPress called with stream:', { url: stream.url, behaviorHints: stream.behaviorHints, useExternalPlayer: settings.useExternalPlayer, preferredPlayer: settings.preferredPlayer }); // For iOS, try to open with the preferred external player if (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal') { try { // Format the URL for the selected player const streamUrl = encodeURIComponent(stream.url); let externalPlayerUrls: string[] = []; // Configure URL formats based on the selected player switch (settings.preferredPlayer) { case 'vlc': externalPlayerUrls = [ `vlc://${stream.url}`, `vlc-x-callback://x-callback-url/stream?url=${streamUrl}`, `vlc://${streamUrl}` ]; break; case 'outplayer': externalPlayerUrls = [ `outplayer://${stream.url}`, `outplayer://${streamUrl}`, `outplayer://play?url=${streamUrl}`, `outplayer://stream?url=${streamUrl}`, `outplayer://play/browser?url=${streamUrl}` ]; break; case 'infuse': externalPlayerUrls = [ `infuse://x-callback-url/play?url=${streamUrl}`, `infuse://play?url=${streamUrl}`, `infuse://${streamUrl}` ]; break; case 'vidhub': externalPlayerUrls = [ `vidhub://play?url=${streamUrl}`, `vidhub://${streamUrl}` ]; break; default: // If no matching player or the setting is somehow invalid, use internal player navigateToPlayer(stream); return; } console.log(`Attempting to open stream in ${settings.preferredPlayer}`); // Try each URL format in sequence const tryNextUrl = (index: number) => { if (index >= externalPlayerUrls.length) { console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`); // Try direct URL as last resort Linking.openURL(stream.url) .then(() => console.log('Opened with direct URL')) .catch(() => { console.log('Direct URL failed, falling back to built-in player'); navigateToPlayer(stream); }); return; } const url = externalPlayerUrls[index]; console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`); Linking.openURL(url) .then(() => console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`)) .catch(err => { console.log(`Format ${index + 1} failed: ${err.message}`, err); tryNextUrl(index + 1); }); }; // Start with the first URL format tryNextUrl(0); } catch (error) { console.error(`Error with ${settings.preferredPlayer}:`, error); // Fallback to the built-in player navigateToPlayer(stream); } } // For Android with external player preference else if (Platform.OS === 'android' && settings.useExternalPlayer) { try { console.log('Opening stream with Android native app chooser'); // For Android, determine if the URL is a direct http/https URL or a magnet link const isMagnet = stream.url.startsWith('magnet:'); if (isMagnet) { // For magnet links, open directly which will trigger the torrent app chooser console.log('Opening magnet link directly'); Linking.openURL(stream.url) .then(() => console.log('Successfully opened magnet link')) .catch(err => { console.error('Failed to open magnet link:', err); // No good fallback for magnet links navigateToPlayer(stream); }); } else { // For direct video URLs, use the S.Browser.ACTION_VIEW approach // This is a more reliable way to force Android to show all video apps // Strip query parameters if they exist as they can cause issues with some apps let cleanUrl = stream.url; if (cleanUrl.includes('?')) { cleanUrl = cleanUrl.split('?')[0]; } // Create an Android intent URL that forces the chooser // Set component=null to ensure chooser is shown // Set action=android.intent.action.VIEW to open the content const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`; console.log(`Using intent URL: ${intentUrl}`); Linking.openURL(intentUrl) .then(() => console.log('Successfully opened with intent URL')) .catch(err => { console.error('Failed to open with intent URL:', err); // First fallback: Try direct URL with regular Linking API console.log('Trying plain URL as fallback'); Linking.openURL(stream.url) .then(() => console.log('Opened with direct URL')) .catch(directErr => { console.error('Failed to open direct URL:', directErr); // Final fallback: Use built-in player console.log('All external player attempts failed, using built-in player'); navigateToPlayer(stream); }); }); } } catch (error) { console.error('Error with external player:', error); // Fallback to the built-in player navigateToPlayer(stream); } } else { // For internal player or if other options failed, use the built-in player navigateToPlayer(stream); } } } catch (error) { console.error('Error in handleStreamPress:', error); // Final fallback: Use built-in player navigateToPlayer(stream); } }, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]); const filterItems = useMemo(() => { const installedAddons = stremioService.getInstalledAddons(); const streams = type === 'series' ? episodeStreams : groupedStreams; // Make sure we include all providers with streams, not just those in availableProviders const allProviders = new Set([ ...availableProviders, ...Object.keys(streams).filter(key => streams[key] && streams[key].streams && streams[key].streams.length > 0 ) ]); return [ { id: 'all', name: 'All Providers' }, ...Array.from(allProviders) .sort((a, b) => { // Always put XPRIME at the top (primary source) if (a === 'xprime') return -1; if (b === 'xprime') return 1; // Then put HDRezka second if (a === 'hdrezka') return -1; if (b === 'hdrezka') return 1; // Then sort by Stremio addon installation order const indexA = installedAddons.findIndex(addon => addon.id === a); const indexB = installedAddons.findIndex(addon => addon.id === b); if (indexA !== -1 && indexB !== -1) return indexA - indexB; if (indexA !== -1) return -1; if (indexB !== -1) return 1; return 0; }) .map(provider => { const addonInfo = streams[provider]; // Special handling for HDRezka if (provider === 'hdrezka') { return { id: provider, name: 'HDRezka' }; } // Standard handling for Stremio addons const installedAddon = installedAddons.find(addon => addon.id === provider); let displayName = provider; if (installedAddon) displayName = installedAddon.name; else if (addonInfo?.addonName) displayName = addonInfo.addonName; return { id: provider, name: displayName }; }) ]; }, [availableProviders, type, episodeStreams, groupedStreams]); const sections = useMemo(() => { const streams = type === 'series' ? episodeStreams : groupedStreams; const installedAddons = stremioService.getInstalledAddons(); // Helper function to extract quality as a number for sorting const getQualityNumeric = (title: string | undefined): number => { if (!title) return 0; // First try to match quality with "p" (e.g., "1080p", "720p") const matchWithP = title.match(/(\d+)p/i); if (matchWithP) { return parseInt(matchWithP[1], 10); } // Then try to match standalone quality numbers at the end of the title // This handles XPRIME format where quality is just "1080", "720", etc. const matchAtEnd = title.match(/\b(\d{3,4})\s*$/); if (matchAtEnd) { const quality = parseInt(matchAtEnd[1], 10); // Only return if it looks like a video quality (between 240 and 8000) if (quality >= 240 && quality <= 8000) { return quality; } } // Try to match quality patterns anywhere in the title with common formats const qualityPatterns = [ /\b(\d{3,4})p\b/i, // 1080p, 720p, etc. /\b(\d{3,4})\s*$/, // 1080, 720 at end /\s(\d{3,4})\s/, // 720 surrounded by spaces /-\s*(\d{3,4})\s*$/, // -720 at end /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i // specific quality values ]; for (const pattern of qualityPatterns) { const match = title.match(pattern); if (match) { const quality = parseInt(match[1], 10); if (quality >= 240 && quality <= 8000) { return quality; } } } return 0; }; // Filter streams by selected provider - only if not "all" const filteredEntries = Object.entries(streams) .filter(([addonId]) => { // If "all" is selected, show all providers if (selectedProvider === 'all') { return true; } // Otherwise only show the selected provider return addonId === selectedProvider; }) .sort(([addonIdA], [addonIdB]) => { // Always put XPRIME at the top (primary source) if (addonIdA === 'xprime') return -1; if (addonIdB === 'xprime') return 1; // Then put HDRezka second if (addonIdA === 'hdrezka') return -1; if (addonIdB === 'hdrezka') return 1; // Then sort by Stremio addon installation order const indexA = installedAddons.findIndex(addon => addon.id === addonIdA); const indexB = installedAddons.findIndex(addon => addon.id === addonIdB); if (indexA !== -1 && indexB !== -1) return indexA - indexB; if (indexA !== -1) return -1; if (indexB !== -1) return 1; return 0; }) .map(([addonId, { addonName, streams: providerStreams }]) => { let sortedProviderStreams = providerStreams; if (addonId === 'hdrezka') { sortedProviderStreams = [...providerStreams].sort((a, b) => { const qualityA = getQualityNumeric(a.title); const qualityB = getQualityNumeric(b.title); return qualityB - qualityA; // Sort descending (e.g., 1080p before 720p) }); } else if (addonId === 'xprime') { // Sort XPRIME streams by quality in descending order (highest quality first) // For XPRIME, quality is in the 'name' field sortedProviderStreams = [...providerStreams].sort((a, b) => { const qualityA = getQualityNumeric(a.name); const qualityB = getQualityNumeric(b.name); return qualityB - qualityA; // Sort descending (e.g., 1080 before 720) }); } return { title: addonName, addonId, data: sortedProviderStreams }; }); return filteredEntries; }, [selectedProvider, type, episodeStreams, groupedStreams]); const episodeImage = useMemo(() => { if (!currentEpisode) return null; if (currentEpisode.still_path) { return tmdbService.getImageUrl(currentEpisode.still_path, 'original'); } return metadata?.poster || null; }, [currentEpisode, metadata]); const isLoading = type === 'series' ? loadingEpisodeStreams : loadingStreams; const streams = type === 'series' ? episodeStreams : groupedStreams; const heroStyle = useAnimatedStyle(() => ({ transform: [{ scale: heroScale.value }], opacity: headerOpacity.value })); const filterStyle = useAnimatedStyle(() => ({ opacity: filterOpacity.value, transform: [ { translateY: interpolate( filterOpacity.value, [0, 1], [20, 0], Extrapolate.CLAMP ) } ] })); const renderItem = useCallback(({ item, index, section }: { item: Stream; index: number; section: any }) => { const stream = item; // Don't show loading for individual streams that are already available and displayed const isLoading = false; // If streams are being rendered, they're available and shouldn't be loading return ( handleStreamPress(stream)} index={index} isLoading={isLoading} statusMessage={undefined} theme={currentTheme} /> ); }, [handleStreamPress, currentTheme]); const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => { const isProviderLoading = loadingProviders[section.addonId]; return ( {section.title} {isProviderLoading && ( Loading... )} ); }, [styles.streamGroupTitle, styles.sectionHeaderContainer, styles.sectionHeaderContent, styles.sectionLoadingIndicator, styles.sectionLoadingText, loadingProviders, colors.primary]); // Cleanup on unmount useEffect(() => { return () => { isMounted.current = false; }; }, []); // Add orientation handling when screen comes into focus useFocusEffect( useCallback(() => { // Set transitioning state to mask any visual glitches setIsTransitioning(true); // Immediately lock to portrait when returning to this screen const lockToPortrait = async () => { try { await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); // Small delay then unlock to allow natural portrait orientation setTimeout(async () => { try { await ScreenOrientation.unlockAsync(); // Clear transition state after orientation is handled setTimeout(() => { setIsTransitioning(false); }, 100); } catch (error) { logger.error('[StreamsScreen] Error unlocking orientation:', error); setIsTransitioning(false); } }, 200); } catch (error) { logger.error('[StreamsScreen] Error locking to portrait:', error); setIsTransitioning(false); } }; lockToPortrait(); return () => { // Cleanup when screen loses focus setIsTransitioning(false); }; }, []) ); return ( {/* Transition overlay to mask orientation changes */} {isTransitioning && ( )} {type === 'series' ? 'Back to Episodes' : 'Back to Info'} {type === 'movie' && metadata && ( {metadata.logo ? ( ) : ( {metadata.name} )} )} {type === 'series' && currentEpisode && ( {currentEpisode.episodeString} {currentEpisode.name} {currentEpisode.overview && ( {currentEpisode.overview} )} {tmdbService.formatAirDate(currentEpisode.air_date)} {currentEpisode.vote_average > 0 && ( {currentEpisode.vote_average.toFixed(1)} )} {currentEpisode.runtime && ( {currentEpisode.runtime >= 60 ? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m` : `${currentEpisode.runtime}m`} )} )} {Object.keys(streams).length > 0 && ( )} {/* Show streams immediately as they become available, with loading indicators for pending providers */} {Object.keys(streams).length === 0 && (loadingStreams || loadingEpisodeStreams) ? ( Finding available streams... ) : Object.keys(streams).length === 0 && !loadingStreams && !loadingEpisodeStreams ? ( No streams available ) : ( item.url || `${item.name}-${item.title}`} renderItem={renderItem} renderSectionHeader={renderSectionHeader} stickySectionHeadersEnabled={false} initialNumToRender={8} maxToRenderPerBatch={4} windowSize={5} removeClippedSubviews={false} contentContainerStyle={styles.streamsContainer} style={styles.streamsContent} showsVerticalScrollIndicator={false} bounces={true} overScrollMode="never" ListFooterComponent={ (loadingStreams || loadingEpisodeStreams) ? ( Loading more sources... ) : null } /> )} ); }; // Create a function to generate styles with the current theme colors const createStyles = (colors: any) => StyleSheet.create({ container: { flex: 1, backgroundColor: colors.darkBackground, }, backButtonContainer: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 2, pointerEvents: 'box-none', }, backButton: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 14, paddingTop: Platform.OS === 'android' ? 20 : 15, }, backButtonText: { color: colors.highEmphasis, fontSize: 13, fontWeight: '600', }, streamsMainContent: { flex: 1, backgroundColor: colors.darkBackground, paddingTop: 20, zIndex: 1, }, streamsMainContentMovie: { paddingTop: Platform.OS === 'android' ? 10 : 15, }, filterContainer: { paddingHorizontal: 16, paddingBottom: 12, }, filterScroll: { flexGrow: 0, }, filterChip: { backgroundColor: colors.transparentLight, paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, marginRight: 8, borderWidth: 1, borderColor: colors.transparent, }, filterChipSelected: { backgroundColor: colors.transparentLight, borderColor: colors.primary, }, filterChipText: { color: colors.text, fontWeight: '500', }, filterChipTextSelected: { color: colors.primary, fontWeight: 'bold', }, streamsContent: { flex: 1, width: '100%', zIndex: 2, }, streamsContainer: { paddingHorizontal: 16, paddingBottom: 16, width: '100%', }, streamGroup: { marginBottom: 24, width: '100%', }, streamGroupTitle: { color: colors.text, fontSize: 16, fontWeight: '600', marginBottom: 4, marginTop: 0, backgroundColor: 'transparent', }, streamCard: { flexDirection: 'row', alignItems: 'flex-start', padding: 12, borderRadius: 12, marginBottom: 8, minHeight: 70, backgroundColor: colors.elevation1, borderWidth: 1, borderColor: 'rgba(255,255,255,0.05)', width: '100%', zIndex: 1, }, streamCardLoading: { opacity: 0.7, }, streamDetails: { flex: 1, }, streamNameRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', width: '100%', flexWrap: 'wrap', gap: 8 }, streamTitleContainer: { flex: 1, }, streamName: { fontSize: 14, fontWeight: '600', marginBottom: 2, lineHeight: 20, color: colors.highEmphasis, }, streamAddonName: { fontSize: 13, lineHeight: 18, color: colors.mediumEmphasis, marginBottom: 6, }, streamMetaRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginBottom: 6, alignItems: 'center', }, chip: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 4, marginRight: 4, marginBottom: 4, }, chipText: { color: colors.highEmphasis, fontSize: 12, fontWeight: '600', }, progressContainer: { height: 20, backgroundColor: colors.transparentLight, borderRadius: 10, overflow: 'hidden', marginBottom: 6, }, progressBar: { height: '100%', backgroundColor: colors.primary, }, progressText: { color: colors.highEmphasis, fontSize: 12, fontWeight: '600', marginLeft: 8, }, streamAction: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.elevation2, justifyContent: 'center', alignItems: 'center', }, skeletonCard: { opacity: 0.7, }, skeletonTitle: { height: 24, width: '40%', backgroundColor: colors.transparentLight, borderRadius: 4, marginBottom: 16, }, skeletonIcon: { width: 24, height: 24, borderRadius: 12, backgroundColor: colors.transparentLight, marginRight: 12, }, skeletonText: { height: 16, borderRadius: 4, marginBottom: 8, backgroundColor: colors.transparentLight, }, skeletonTag: { width: 60, height: 20, borderRadius: 4, marginRight: 8, backgroundColor: colors.transparentLight, }, noStreams: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32, }, noStreamsText: { color: colors.textMuted, fontSize: 16, marginTop: 16, }, streamsHeroContainer: { width: '100%', height: 220, marginBottom: 0, position: 'relative', backgroundColor: colors.black, pointerEvents: 'box-none', }, streamsHeroBackground: { width: '100%', height: '100%', backgroundColor: colors.black, }, streamsHeroGradient: { flex: 1, justifyContent: 'flex-end', padding: 16, paddingBottom: 0, }, streamsHeroContent: { width: '100%', }, streamsHeroInfo: { width: '100%', }, streamsHeroEpisodeNumber: { color: colors.primary, fontSize: 14, fontWeight: 'bold', marginBottom: 2, textShadowColor: 'rgba(0,0,0,0.75)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2, }, streamsHeroTitle: { color: colors.highEmphasis, fontSize: 24, fontWeight: 'bold', marginBottom: 4, textShadowColor: 'rgba(0,0,0,0.75)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 3, }, streamsHeroOverview: { color: colors.mediumEmphasis, fontSize: 14, lineHeight: 20, marginBottom: 2, textShadowColor: 'rgba(0,0,0,0.75)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2, }, streamsHeroMeta: { flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 0, }, streamsHeroReleased: { color: colors.mediumEmphasis, fontSize: 14, textShadowColor: 'rgba(0,0,0,0.75)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2, }, streamsHeroRating: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.7)', paddingHorizontal: 6, paddingVertical: 3, borderRadius: 4, marginTop: 0, }, tmdbLogo: { width: 20, height: 14, }, streamsHeroRatingText: { color: colors.accent, fontSize: 13, fontWeight: '700', marginLeft: 4, }, loadingContainer: { alignItems: 'center', paddingVertical: 24, }, loadingText: { color: colors.primary, fontSize: 12, marginLeft: 4, fontWeight: '500', }, downloadingIndicator: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.transparentLight, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 12, marginLeft: 8, }, downloadingText: { color: colors.primary, fontSize: 12, marginLeft: 4, fontWeight: '500', }, loadingIndicator: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 2, borderRadius: 12, marginLeft: 8, }, footerLoading: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', padding: 16, }, footerLoadingText: { color: colors.primary, fontSize: 12, marginLeft: 8, fontWeight: '500', }, movieTitleContainer: { width: '100%', height: 200, backgroundColor: colors.black, pointerEvents: 'box-none', }, movieTitleBackground: { width: '100%', height: '100%', backgroundColor: colors.black, }, movieTitleGradient: { flex: 1, justifyContent: 'center', padding: 16, }, movieTitleContent: { width: '100%', alignItems: 'center', marginTop: Platform.OS === 'android' ? 35 : 45, }, movieLogo: { width: width * 0.6, height: 70, marginBottom: 8, }, movieTitle: { color: colors.highEmphasis, fontSize: 28, fontWeight: '900', textAlign: 'center', textShadowColor: 'rgba(0,0,0,0.75)', textShadowOffset: { width: 0, height: 2 }, textShadowRadius: 4, letterSpacing: -0.5, }, streamsHeroRuntime: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, }, streamsHeroRuntimeText: { color: colors.mediumEmphasis, fontSize: 13, fontWeight: '600', }, transitionOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: colors.darkBackground, justifyContent: 'center', alignItems: 'center', zIndex: 9999, }, sectionHeaderContainer: { padding: 16, }, sectionHeaderContent: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, sectionLoadingIndicator: { flexDirection: 'row', alignItems: 'center', }, sectionLoadingText: { marginLeft: 8, }, }); export default memo(StreamsScreen);