import React, { useCallback, useMemo, memo, useState, useEffect, useRef, useLayoutEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, FlatList, SectionList, Platform, ImageBackground, ScrollView, StatusBar, Dimensions, Linking, Clipboard, Image as RNImage, } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withTiming, withDelay, runOnJS } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { useMetadataAssets } from '../hooks/useMetadataAssets'; import { useTheme } from '../contexts/ThemeContext'; import { useTrailer } from '../contexts/TrailerContext'; import { Stream } from '../types/metadata'; import { tmdbService } from '../services/tmdbService'; import { stremioService } from '../services/stremioService'; import { localScraperService } from '../services/localScraperService'; import { VideoPlayerService } from '../services/videoPlayerService'; import { useSettings } from '../hooks/useSettings'; import QualityBadge from '../components/metadata/QualityBadge'; import { logger } from '../utils/logger'; import { isMkvStream } from '../utils/mkvDetection'; import CustomAlert from '../components/CustomAlert'; import { Toast } from 'toastify-react-native'; import { useDownloads } from '../contexts/DownloadsContext'; 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'); // Cache for scraper logos to avoid repeated async calls const scraperLogoCache = new Map(); let scraperLogoCachePromise: Promise | null = null; // Short-budget HEAD detection to avoid long delays before navigation const MKV_HEAD_TIMEOUT_MS = 600; const detectMkvViaHead = async (url: string, headers?: Record) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), MKV_HEAD_TIMEOUT_MS); try { const res = await fetch(url, { method: 'HEAD', headers, signal: controller.signal as any, } as any); const contentType = res.headers.get('content-type') || ''; return /matroska|x-matroska/i.test(contentType); } catch (_e) { return false; } finally { clearTimeout(timeout); } }; // Animated Components const AnimatedImage = memo(({ source, style, contentFit, onLoad }: { source: { uri: string } | undefined; style: any; contentFit: any; onLoad?: () => void; }) => { const opacity = useSharedValue(0); const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); useEffect(() => { if (source?.uri) { opacity.value = withTiming(1, { duration: 300 }); } else { opacity.value = 0; } }, [source?.uri]); // Cleanup on unmount useEffect(() => { return () => { opacity.value = 0; }; }, []); return ( ); }); const AnimatedText = memo(({ children, style, delay = 0, numberOfLines }: { children: React.ReactNode; style: any; delay?: number; numberOfLines?: number; }) => { const opacity = useSharedValue(0); const translateY = useSharedValue(20); const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, transform: [{ translateY: translateY.value }], })); useEffect(() => { opacity.value = withDelay(delay, withTiming(1, { duration: 250 })); translateY.value = withDelay(delay, withTiming(0, { duration: 250 })); }, [delay]); // Cleanup on unmount useEffect(() => { return () => { opacity.value = 0; translateY.value = 20; }; }, []); return ( {children} ); }); const AnimatedView = memo(({ children, style, delay = 0 }: { children: React.ReactNode; style?: any; delay?: number; }) => { const opacity = useSharedValue(0); const translateY = useSharedValue(20); const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, transform: [{ translateY: translateY.value }], })); useEffect(() => { opacity.value = withDelay(delay, withTiming(1, { duration: 250 })); translateY.value = withDelay(delay, withTiming(0, { duration: 250 })); }, [delay]); // Cleanup on unmount useEffect(() => { return () => { opacity.value = 0; translateY.value = 20; }; }, []); return ( {children} ); }); // Extracted Components const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos, scraperLogo, showAlert, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName }: { stream: Stream; onPress: () => void; index: number; isLoading?: boolean; statusMessage?: string; theme: any; showLogos?: boolean; scraperLogo?: string | null; showAlert: (title: string, message: string) => void; parentTitle?: string; parentType?: 'movie' | 'series'; parentSeason?: number; parentEpisode?: number; parentEpisodeTitle?: string; parentPosterUrl?: string | null; providerName?: string; }) => { const { useSettings } = require('../hooks/useSettings'); const { settings } = useSettings(); const { startDownload } = useDownloads(); // Handle long press to copy stream URL to clipboard const handleLongPress = useCallback(async () => { if (stream.url) { try { await Clipboard.setString(stream.url); // Use toast for Android, custom alert for iOS if (Platform.OS === 'android') { Toast.success('Stream URL copied to clipboard!', 'bottom'); } else { // iOS uses custom alert setTimeout(() => { showAlert('Copied!', 'Stream URL has been copied to clipboard.'); }, 50); } } catch (error) { // Fallback: show URL in alert if clipboard fails if (Platform.OS === 'android') { Toast.info(`Stream URL: ${stream.url}`, 'bottom'); } else { setTimeout(() => { showAlert('Stream URL', stream.url); }, 50); } } } }, [stream.url, showAlert]); const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); const streamInfo = useMemo(() => { const title = stream.title || ''; const name = stream.name || ''; // Helper function to format size from bytes const formatSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; // Get size from title (legacy format) or from stream.size field let sizeDisplay = title.match(/πŸ’Ύ\s*([\d.]+\s*[GM]B)/)?.[1]; if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) { sizeDisplay = formatSize(stream.size); } // Extract quality for badge display const basicQuality = title.match(/(\d+)p/)?.[1] || null; return { quality: basicQuality, isHDR: title.toLowerCase().includes('hdr'), isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'), size: sizeDisplay, isDebrid: stream.behaviorHints?.cached, displayName: name || 'Unnamed Stream', subTitle: title && title !== name ? title : null }; }, [stream.name, stream.title, stream.behaviorHints, stream.size]); // Logo is provided by parent to avoid per-card async work const handleDownload = useCallback(async () => { try { const url = stream.url; if (!url) return; const parent: any = stream as any; const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content'; const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie'); const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number); const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number); const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name; // Prefer the stream's display name (often includes provider + resolution) const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider'; const idForContent = parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle; await startDownload({ id: String(idForContent), type: inferredType, title: String(inferredTitle), providerName: String(provider), season: inferredType === 'series' ? (season ? Number(season) : undefined) : undefined, episode: inferredType === 'series' ? (episode ? Number(episode) : undefined) : undefined, episodeTitle: inferredType === 'series' ? (episodeTitle ? String(episodeTitle) : undefined) : undefined, quality: streamInfo.quality || undefined, posterUrl: parentPosterUrl || parent.poster || parent.backdrop || null, url, headers: (stream.headers as any) || undefined, }); Toast.success('Download started', 'bottom'); } catch {} }, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title]); const isDebrid = streamInfo.isDebrid; return ( {/* Scraper Logo */} {showLogos && scraperLogo && ( )} {streamInfo.displayName} {streamInfo.subTitle && ( {streamInfo.subTitle} )} {/* Show loading indicator if stream is loading */} {isLoading && ( {statusMessage || "Loading..."} )} {streamInfo.isDolby && ( )} {streamInfo.size && ( πŸ’Ύ {streamInfo.size} )} {streamInfo.isDebrid && ( DEBRID )} {settings?.enableDownloads !== false && ( )} ); }); 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 PulsingChip = memo(({ text, delay }: { text: string; delay: number }) => { const { currentTheme } = useTheme(); const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]); // Make chip static to avoid continuous animation load 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} removeClippedSubviews={true} getItemLayout={(data, index) => ({ length: 100, // Approximate width of each item offset: 100 * index, index, })} /> ); }); export const StreamsScreen = () => { const insets = useSafeAreaInsets(); const route = useRoute>(); const navigation = useNavigation(); const { id, type, episodeId, episodeThumbnail, fromPlayer } = route.params; const { settings } = useSettings(); const { currentTheme } = useTheme(); const { colors } = currentTheme; const { pauseTrailer, resumeTrailer } = useTrailer(); // Add refs to prevent excessive updates and duplicate loads const isMounted = useRef(true); const loadStartTimeRef = useRef(0); const hasDoneInitialLoadRef = useRef(false); const isLoadingStreamsRef = useRef(false); // CustomAlert state const [alertVisible, setAlertVisible] = useState(false); const [alertTitle, setAlertTitle] = useState(''); const [alertMessage, setAlertMessage] = useState(''); const [alertActions, setAlertActions] = useState void; style?: object }>>([]); const openAlert = useCallback(( title: string, message: string, actions?: Array<{ label: string; onPress: () => void; style?: object }> ) => { // Add safety check to prevent crashes on Android if (!isMounted.current) { return; } try { setAlertTitle(title); setAlertMessage(message); setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); setAlertVisible(true); } catch (error) { console.warn('[StreamsScreen] Error showing alert:', error); } }, []); // Track when we started fetching streams so we can show an extended loading state const [streamsLoadStart, setStreamsLoadStart] = useState(null); const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); // Prevent excessive re-renders by using this guard const guardedSetState = useCallback((setter: () => void) => { if (isMounted.current) { setter(); } }, []); useEffect(() => { if (__DEV__) console.log('[StreamsScreen] Received thumbnail from params:', episodeThumbnail); }, [episodeThumbnail]); // Reset movie logo error when movie changes useEffect(() => { setMovieLogoError(false); }, [id]); // Pause trailer when StreamsScreen is opened useEffect(() => { // Pause trailer when component mounts pauseTrailer(); // Resume trailer when component unmounts return () => { resumeTrailer(); }; }, [pauseTrailer, resumeTrailer]); const { metadata, episodes, groupedStreams, loadingStreams, episodeStreams, loadingEpisodeStreams, selectedEpisode, loadStreams, loadEpisodeStreams, setSelectedEpisode, groupedEpisodes, imdbId, scraperStatuses, activeFetchingScrapers, addonResponseOrder, } = useMetadata({ id, type }); // Get backdrop from metadata assets const setMetadataStub = useCallback(() => {}, []); const memoizedSettings = useMemo(() => settings, [settings.logoSourcePreference, settings.tmdbLanguagePreference]); const { bannerImage } = useMetadataAssets(metadata, id, type, imdbId, memoizedSettings, setMetadataStub); // 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()); // 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; } }>({}); // Add state for autoplay functionality const [autoplayTriggered, setAutoplayTriggered] = useState(false); const [isAutoplayWaiting, setIsAutoplayWaiting] = useState(false); // Add check for available streaming sources const [hasStreamProviders, setHasStreamProviders] = useState(true); // Assume true initially const [hasStremioStreamProviders, setHasStremioStreamProviders] = useState(true); // For footer logic // Add state for no sources error const [showNoSourcesError, setShowNoSourcesError] = useState(false); // State for movie logo loading error const [movieLogoError, setMovieLogoError] = useState(false); // Scraper logos map to avoid per-card async fetches const [scraperLogos, setScraperLogos] = useState>({}); // Preload scraper logos once and expose via state React.useEffect(() => { const preloadScraperLogos = async () => { if (!scraperLogoCachePromise) { scraperLogoCachePromise = (async () => { try { const availableScrapers = await localScraperService.getAvailableScrapers(); const map: Record = {}; availableScrapers.forEach(scraper => { if (scraper.logo && scraper.id) { scraperLogoCache.set(scraper.id, scraper.logo); map[scraper.id] = scraper.logo; } }); setScraperLogos(map); } catch (error) { // Silently fail } })(); } else { // If already loading, update state after it resolves scraperLogoCachePromise.then(() => { // Build map from cache const map: Record = {}; // No direct way to iterate Map keys safely without exposing it; copy known ids on demand during render setScraperLogos(prev => prev); // no-op to ensure consistency }).catch(() => {}); } }; preloadScraperLogos(); }, []); // Monitor streams loading and update available providers immediately useEffect(() => { // Skip processing if component is unmounting if (!isMounted.current) return; const currentStreamsData = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; if (__DEV__) console.log('[StreamsScreen] streams state changed', { providerKeys: Object.keys(currentStreamsData || {}), type }); // Update available providers immediately when streams change const providersWithStreams = Object.entries(currentStreamsData) .filter(([_, data]) => data.streams && data.streams.length > 0) .map(([providerId]) => providerId); if (providersWithStreams.length > 0) { logger.log(`πŸ“Š Providers with streams: ${providersWithStreams.join(', ')}`); const providersWithStreamsSet = new Set(providersWithStreams); // Only update if we have new providers, don't remove existing ones during loading setAvailableProviders(prevProviders => { const newProviders = new Set([...prevProviders, ...providersWithStreamsSet]); if (__DEV__) console.log('[StreamsScreen] availableProviders ->', Array.from(newProviders)); return newProviders; }); } // Update loading states for individual providers const expectedProviders = ['stremio']; const now = Date.now(); setLoadingProviders(prevLoading => { const nextLoading = { ...prevLoading }; let changed = false; expectedProviders.forEach(providerId => { const hasStreams = currentStreamsData[providerId] && currentStreamsData[providerId].streams && currentStreamsData[providerId].streams.length > 0; const value = (loadingStreams || loadingEpisodeStreams) && !hasStreams; if (nextLoading[providerId] !== value) { nextLoading[providerId] = value; changed = true; } }); if (changed && __DEV__) console.log('[StreamsScreen] loadingProviders ->', nextLoading); return changed ? nextLoading : prevLoading; }); }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type]); // Reset autoplay state when episode changes (but preserve fromPlayer logic) useEffect(() => { // Reset autoplay triggered state when episode changes // This allows autoplay to work for each episode individually setAutoplayTriggered(false); }, [selectedEpisode]); // Reset the selected provider to 'all' if the current selection is no longer available // But preserve special filter values like 'grouped-plugins' and 'all' useEffect(() => { // Don't reset if it's a special filter value const isSpecialFilter = selectedProvider === 'all' || selectedProvider === 'grouped-plugins'; if (isSpecialFilter) { return; // Always preserve special filters } // Check if provider exists in current streams data const currentStreamsData = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; const hasStreamsForProvider = currentStreamsData[selectedProvider] && currentStreamsData[selectedProvider].streams && currentStreamsData[selectedProvider].streams.length > 0; // Only reset if the provider doesn't exist in available providers AND doesn't have streams const isAvailableProvider = availableProviders.has(selectedProvider); if (!isAvailableProvider && !hasStreamsForProvider) { setSelectedProvider('all'); } }, [selectedProvider, availableProviders, episodeStreams, groupedStreams, type]); // Removed global/local cached results pre-check on mount // Update useEffect to check for sources useEffect(() => { // Reset initial load state when content changes hasDoneInitialLoadRef.current = false; isLoadingStreamsRef.current = false; const checkProviders = async () => { if (__DEV__) console.log('[StreamsScreen] checkProviders() start', { id, type, episodeId, fromPlayer }); logger.log(`[StreamsScreen] checkProviders() start id=${id} type=${type} episodeId=${episodeId || 'none'} fromPlayer=${!!fromPlayer}`); // Prevent duplicate calls if already loading if (isLoadingStreamsRef.current) { if (__DEV__) console.log('[StreamsScreen] checkProviders() skipping - already loading'); return; } isLoadingStreamsRef.current = true; try { // Check for Stremio addons const hasStremioProviders = await stremioService.hasStreamProviders(); if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders); // Check for local scrapers (only if enabled in settings) const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers(); if (__DEV__) console.log('[StreamsScreen] hasLocalScrapers:', hasLocalScrapers, 'enableLocalScrapers:', settings.enableLocalScrapers); // We have providers if we have Stremio addons OR enabled local scrapers // Note: Cached results do NOT count as active providers - they are just old data const hasProviders = hasStremioProviders || hasLocalScrapers; logger.log(`[StreamsScreen] provider check: hasProviders=${hasProviders} (stremio:${hasStremioProviders}, local:${hasLocalScrapers})`); if (!isMounted.current) return; setHasStreamProviders(hasProviders); setHasStremioStreamProviders(hasStremioProviders); if (!hasProviders) { logger.log('[StreamsScreen] No providers detected; showing no-sources UI'); const timer = setTimeout(() => { if (isMounted.current) setShowNoSourcesError(true); }, 500); return () => clearTimeout(timer); } else { // Removed cached streams pre-display logic // For series episodes, do not wait for metadata; load directly when episodeId is present if (episodeId) { logger.log(`🎬 Loading episode streams for: ${episodeId}`); setLoadingProviders({ 'stremio': true }); setSelectedEpisode(episodeId); setStreamsLoadStart(Date.now()); if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId); loadEpisodeStreams(episodeId); } else if (type === 'movie') { logger.log(`🎬 Loading movie streams for: ${id}`); setStreamsLoadStart(Date.now()); if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id); loadStreams(); } else if (type === 'tv') { // TV/live content – fetch streams directly logger.log(`πŸ“Ί Loading TV streams for: ${id}`); setLoadingProviders({ 'stremio': true }); setStreamsLoadStart(Date.now()); if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id); loadStreams(); } else { // Fallback: series without explicit episodeId (or other types) – fetch streams directly logger.log(`🎬 Loading streams for: ${id}`); setLoadingProviders({ 'stremio': true }); setStreamsLoadStart(Date.now()); if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id); loadStreams(); } // Reset autoplay state when content changes setAutoplayTriggered(false); if (settings.autoplayBestStream && !fromPlayer) { setIsAutoplayWaiting(true); logger.log('πŸ”„ Autoplay enabled, waiting for best stream...'); } else { setIsAutoplayWaiting(false); if (fromPlayer) { logger.log('🚫 Autoplay disabled: returning from player'); } } } } finally { isLoadingStreamsRef.current = false; } }; checkProviders(); }, [type, id, episodeId, settings.autoplayBestStream, fromPlayer]); // Memoize handlers const handleBack = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack(); } else { (navigation as any).navigate('MainTabs'); } }, [navigation]); const handleProviderChange = useCallback((provider: string) => { setSelectedProvider(provider); }, []); // Helper function to filter streams by quality exclusions const filterStreamsByQuality = useCallback((streams: Stream[]) => { if (!settings.excludedQualities || settings.excludedQualities.length === 0) { return streams; } return streams.filter(stream => { const streamTitle = stream.title || stream.name || ''; // Check if any excluded quality is found in the stream title const hasExcludedQuality = settings.excludedQualities.some(excludedQuality => { if (excludedQuality === 'Auto') { // Special handling for Auto quality - check for Auto or Adaptive return /\b(auto|adaptive)\b/i.test(streamTitle); } else { // Create a case-insensitive regex pattern for other qualities const pattern = new RegExp(excludedQuality.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); return pattern.test(streamTitle); } }); // Return true to keep the stream (if it doesn't have excluded quality) return !hasExcludedQuality; }); }, [settings.excludedQualities]); // Note: No additional sorting applied to stream cards; preserve provider order // Function to determine the best stream based on quality, provider priority, and other factors const getBestStream = useCallback((streamsData: typeof groupedStreams): Stream | null => { if (!streamsData || Object.keys(streamsData).length === 0) { return null; } // Helper function to extract quality as number const getQualityNumeric = (title: string | undefined): number => { if (!title) return 0; // Check for 4K first (treat as 2160p) if (/\b4k\b/i.test(title)) { return 2160; } const matchWithP = title.match(/(\d+)p/i); if (matchWithP) return parseInt(matchWithP[1], 10); const qualityPatterns = [ /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i ]; 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; }; // Provider priority (higher number = higher priority) const getProviderPriority = (addonId: string): number => { // Get Stremio addon installation order (earlier = higher priority) const installedAddons = stremioService.getInstalledAddons(); const addonIndex = installedAddons.findIndex(addon => addon.id === addonId); if (addonIndex !== -1) { // Higher priority for addons installed earlier (reverse index) return 50 - addonIndex; } return 0; // Unknown providers get lowest priority }; // Collect all streams with metadata const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number; }> = []; Object.entries(streamsData).forEach(([addonId, { streams }]) => { // Apply quality filtering to streams before processing const filteredStreams = filterStreamsByQuality(streams); filteredStreams.forEach(stream => { const quality = getQualityNumeric(stream.name || stream.title); const providerPriority = getProviderPriority(addonId); allStreams.push({ stream, quality, providerPriority, }); }); }); if (allStreams.length === 0) return null; // Sort streams by multiple criteria (best first) allStreams.sort((a, b) => { // 1. Prioritize higher quality if (a.quality !== b.quality) { return b.quality - a.quality; } // 2. Prioritize better providers if (a.providerPriority !== b.providerPriority) { return b.providerPriority - a.providerPriority; } return 0; }); logger.log(`🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p, Provider Priority: ${allStreams[0].providerPriority})`); return allStreams[0].stream; }, [filterStreamsByQuality]); 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]); // TMDB hydration for series hero (rating/runtime/still) const [tmdbEpisodeOverride, setTmdbEpisodeOverride] = useState<{ vote_average?: number; runtime?: number; still_path?: string } | null>(null); useEffect(() => { const hydrateEpisodeFromTmdb = async () => { try { setTmdbEpisodeOverride(null); if (type !== 'series' || !currentEpisode || !id) return; // Skip if data already present const needsHydration = !(currentEpisode as any).runtime || !(currentEpisode as any).vote_average || !currentEpisode.still_path; if (!needsHydration) return; // Resolve TMDB show id let tmdbShowId: number | null = null; if (id.startsWith('tmdb:')) { tmdbShowId = parseInt(id.split(':')[1], 10); } else if (id.startsWith('tt')) { tmdbShowId = await tmdbService.findTMDBIdByIMDB(id); } if (!tmdbShowId) return; const allEpisodes: Record = await tmdbService.getAllEpisodes(tmdbShowId) as any; const seasonKey = String(currentEpisode.season_number); const seasonList: any[] = (allEpisodes && (allEpisodes as any)[seasonKey]) || []; const ep = seasonList.find((e: any) => e.episode_number === currentEpisode.episode_number); if (ep) { setTmdbEpisodeOverride({ vote_average: ep.vote_average, runtime: ep.runtime, still_path: ep.still_path, }); } } catch (e) { logger.warn('[StreamsScreen] TMDB hydration failed:', e); } }; hydrateEpisodeFromTmdb(); }, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]); const navigateToPlayer = useCallback(async (stream: Stream, options?: { forceVlc?: boolean; headers?: Record }) => { // Add 50ms delay before navigating to player await new Promise(resolve => setTimeout(resolve, 50)); // Prepare available streams for the change source feature const streamsToPass = (type === 'series' || (type === 'other' && selectedEpisode)) ? episodeStreams : groupedStreams; // Determine the stream name using the same logic as StreamCard const streamName = stream.name || stream.title || 'Unnamed Stream'; const streamProvider = stream.addonId || stream.addonName || stream.name; // Do NOT pre-force VLC. Let ExoPlayer try first; fallback occurs on decoder error in the player. let forceVlc = !!options?.forceVlc; // Show a quick full-screen black overlay to mask rotation flicker // by setting a transient state that renders a covering View (implementation already supported by dark backgrounds) // Infer video type for player (helps Android ExoPlayer choose correct extractor) const inferVideoTypeFromUrl = (u?: string): string | undefined => { if (!u) return undefined; const lower = u.toLowerCase(); if (/(\.|ext=)(m3u8)(\b|$)/i.test(lower)) return 'm3u8'; if (/(\.|ext=)(mpd)(\b|$)/i.test(lower)) return 'mpd'; if (/(\.|ext=)(mp4)(\b|$)/i.test(lower)) return 'mp4'; return undefined; }; let videoType = inferVideoTypeFromUrl(stream.url); // Heuristic: certain providers (e.g., Xprime) serve HLS without .m3u8 extension try { const providerId = stream.addonId || (stream as any).addon || ''; if (!videoType && /xprime/i.test(providerId)) { videoType = 'm3u8'; } } catch {} // Simple platform check - iOS uses KSPlayerCore, Android uses AndroidVideoPlayer const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; navigation.navigate(playerRoute as any, { uri: stream.url, title: metadata?.name || '', episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined, season: (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined, episode: (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined, quality: (stream.title?.match(/(\d+)p/) || [])[1] || undefined, year: metadata?.year, streamProvider: streamProvider, streamName: streamName, // Always prefer stream.headers; player will use these for requests headers: options?.headers || stream.headers || undefined, // Android will use this to choose VLC path; iOS ignores forceVlc, id, type, episodeId: (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined, imdbId: imdbId || undefined, availableStreams: streamsToPass, backdrop: bannerImage || undefined, // Hint for Android ExoPlayer/react-native-video videoType: videoType, } as any); }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage]); // Update handleStreamPress const handleStreamPress = useCallback(async (stream: Stream) => { try { if (stream.url) { // Block magnet links - not supported yet if (stream.url.startsWith('magnet:')) { try { openAlert('Not supported', 'Torrent streaming is not supported yet.'); } catch (_e) {} return; } // If stream is actually MKV format, force the in-app VLC-based player on iOS try { if (Platform.OS === 'ios' && settings.preferredPlayer === 'internal') { // Check if the actual stream is an MKV file const lowerUri = (stream.url || '').toLowerCase(); // iOS now always uses KSPlayer, no need for format-specific logic // Keep this for logging purposes only const contentType = (stream.headers && ((stream.headers as any)['Content-Type'] || (stream.headers as any)['content-type'])) || ''; const isMkvByHeader = typeof contentType === 'string' && contentType.includes('matroska'); const isMkvByPath = lowerUri.includes('.mkv') || /[?&]ext=mkv\b/.test(lowerUri) || /format=mkv\b/.test(lowerUri) || /container=mkv\b/.test(lowerUri); const isMkvFile = Boolean(isMkvByHeader || isMkvByPath); if (isMkvFile) { logger.log(`[StreamsScreen] Stream is MKV format - will play in KSPlayer on iOS`); } } } catch (err) { logger.warn('[StreamsScreen] Stream format pre-check failed:', err); } // iOS: very short MKV detection race; never block longer than MKV_HEAD_TIMEOUT_MS if (Platform.OS === 'ios' && settings.preferredPlayer === 'internal') { const lowerUrl = (stream.url || '').toLowerCase(); const isMkvByPath = lowerUrl.includes('.mkv') || /[?&]ext=mkv\b/i.test(lowerUrl) || /format=mkv\b/i.test(lowerUrl) || /container=mkv\b/i.test(lowerUrl); const isHttp = lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); if (!isMkvByPath && isHttp) { try { const mkvDetected = await Promise.race([ detectMkvViaHead(stream.url, (stream.headers as any) || undefined), new Promise(res => setTimeout(() => res(false), MKV_HEAD_TIMEOUT_MS)), ]); if (mkvDetected) { const mergedHeaders = { ...(stream.headers || {}), 'Content-Type': 'video/x-matroska', } as Record; logger.log('[StreamsScreen] HEAD detected MKV via Content-Type - will play in KSPlayer on iOS'); navigateToPlayer(stream, { headers: mergedHeaders }); return; } } catch (e) { logger.warn('[StreamsScreen] Short MKV detection failed:', e); } } } 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; } if (__DEV__) console.log(`Attempting to open stream in ${settings.preferredPlayer}`); // Try each URL format in sequence const tryNextUrl = (index: number) => { if (index >= externalPlayerUrls.length) { if (__DEV__) console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`); // Try direct URL as last resort Linking.openURL(stream.url) .then(() => { if (__DEV__) console.log('Opened with direct URL'); }) .catch(() => { if (__DEV__) console.log('Direct URL failed, falling back to built-in player'); navigateToPlayer(stream); }); return; } const url = externalPlayerUrls[index]; if (__DEV__) console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`); Linking.openURL(url) .then(() => { if (__DEV__) console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`); }) .catch(err => { if (__DEV__) console.log(`Format ${index + 1} failed: ${err.message}`, err); tryNextUrl(index + 1); }); }; // Start with the first URL format tryNextUrl(0); } catch (error) { if (__DEV__) 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 { if (__DEV__) 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 if (__DEV__) console.log('Opening magnet link directly'); Linking.openURL(stream.url) .then(() => { if (__DEV__) console.log('Successfully opened magnet link'); }) .catch(err => { if (__DEV__) console.error('Failed to open magnet link:', err); // No good fallback for magnet links navigateToPlayer(stream); }); } else { // For direct video URLs, use the VideoPlayerService to show the Android app chooser const success = await VideoPlayerService.playVideo(stream.url, { useExternalPlayer: true, title: metadata?.name || 'Video', episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined, episodeNumber: (type === 'series' || type === 'other') && currentEpisode ? `S${currentEpisode.season_number}E${currentEpisode.episode_number}` : undefined, }); if (!success) { if (__DEV__) console.log('VideoPlayerService failed, falling back to built-in player'); navigateToPlayer(stream); } } } catch (error) { if (__DEV__) 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) { if (__DEV__) console.error('Error in handleStreamPress:', error); // Final fallback: Use built-in player navigateToPlayer(stream); } }, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]); // Ensure portrait when returning to this screen on iOS useFocusEffect( useCallback(() => { if (Platform.OS === 'ios') { // Add delay before locking orientation to prevent background glitches const orientationTimer = setTimeout(() => { ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {}); }, 200); // Small delay to let the screen render properly // iOS-specific: Force a re-render to prevent background glitches // This helps ensure the background is properly rendered when returning from player const renderTimer = setTimeout(() => { // Trigger a small state update to force re-render setStreamsLoadStart(prev => prev); }, 100); return () => { clearTimeout(orientationTimer); clearTimeout(renderTimer); }; } return () => {}; }, []) ); // Autoplay effect - triggers immediately when streams are available and autoplay is enabled useEffect(() => { if ( settings.autoplayBestStream && !autoplayTriggered && isAutoplayWaiting ) { const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; if (Object.keys(streams).length > 0) { const bestStream = getBestStream(streams); if (bestStream) { logger.log('πŸš€ Autoplay: Best stream found, starting playback immediately...'); setAutoplayTriggered(true); setIsAutoplayWaiting(false); // Start playback immediately - no delay needed handleStreamPress(bestStream); } else { logger.log('⚠️ Autoplay: No suitable stream found'); setIsAutoplayWaiting(false); } } } }, [ settings.autoplayBestStream, autoplayTriggered, isAutoplayWaiting, type, episodeStreams, groupedStreams, getBestStream, handleStreamPress ]); const filterItems = useMemo(() => { const installedAddons = stremioService.getInstalledAddons(); const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? 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 ) ]); // In grouped mode, separate addons and plugins if (settings.streamDisplayMode === 'grouped') { const addonProviders: string[] = []; const pluginProviders: string[] = []; Array.from(allProviders).forEach(provider => { const isInstalledAddon = installedAddons.some(addon => addon.id === provider); if (isInstalledAddon) { addonProviders.push(provider); } else { pluginProviders.push(provider); } }); const filterChips = [{ id: 'all', name: 'All Providers' }]; // Add individual addon chips addonProviders .sort((a, b) => { const indexA = installedAddons.findIndex(addon => addon.id === a); const indexB = installedAddons.findIndex(addon => addon.id === b); return indexA - indexB; }) .forEach(provider => { const installedAddon = installedAddons.find(addon => addon.id === provider); filterChips.push({ id: provider, name: installedAddon?.name || provider }); }); // Add single grouped plugins chip if there are any plugins if (pluginProviders.length > 0) { filterChips.push({ id: 'grouped-plugins', name: localScraperService.getRepositoryName() }); } return filterChips; } // Normal mode - individual chips for all providers return [ { id: 'all', name: 'All Providers' }, ...Array.from(allProviders) .sort((a, b) => { // 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]; // 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, settings.streamDisplayMode]); const sections = useMemo(() => { const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; const installedAddons = stremioService.getInstalledAddons(); // Filter streams by selected provider const filteredEntries = Object.entries(streams) .filter(([addonId]) => { // If "all" is selected, show all providers if (selectedProvider === 'all') { return true; } // In grouped mode, handle special 'grouped-plugins' filter if (settings.streamDisplayMode === 'grouped' && selectedProvider === 'grouped-plugins') { const isInstalledAddon = installedAddons.some(addon => addon.id === addonId); return !isInstalledAddon; // Show only plugins (non-installed addons) } // Otherwise only show the selected provider return addonId === selectedProvider; }) .sort(([addonIdA], [addonIdB]) => { // Sort by response order (actual order addons responded) const indexA = addonResponseOrder.indexOf(addonIdA); const indexB = addonResponseOrder.indexOf(addonIdB); // If both are in response order, sort by response order if (indexA !== -1 && indexB !== -1) { return indexA - indexB; } // If only one is in response order, prioritize it if (indexA !== -1) return -1; if (indexB !== -1) return 1; // If neither is in response order, maintain original order return 0; }); // Check if we should group all streams under one section if (settings.streamDisplayMode === 'grouped') { // Separate addon and plugin streams - only apply quality filtering/sorting to plugins const addonStreams: Stream[] = []; const pluginStreams: Stream[] = []; let totalOriginalCount = 0; filteredEntries.forEach(([addonId, { addonName, streams: providerStreams }]) => { const isInstalledAddon = installedAddons.some(addon => addon.id === addonId); // Count original streams before filtering totalOriginalCount += providerStreams.length; if (isInstalledAddon) { // For ADDONS: Keep all streams in original order, NO filtering or sorting addonStreams.push(...providerStreams); } else { // For PLUGINS: Apply quality filtering and sorting const filteredStreams = filterStreamsByQuality(providerStreams); if (filteredStreams.length > 0) { pluginStreams.push(...filteredStreams); } } }); const totalStreamsCount = addonStreams.length + pluginStreams.length; const isEmptyDueToQualityFilter = totalOriginalCount > 0 && totalStreamsCount === 0; if (isEmptyDueToQualityFilter) { return [{ title: 'Available Streams', addonId: 'grouped-all', data: [{ isEmptyPlaceholder: true } as any], isEmptyDueToQualityFilter: true }]; } // Combine streams: Addons first (unsorted), then sorted plugins let combinedStreams = [...addonStreams]; // Apply quality sorting to PLUGIN streams when enabled if (settings.streamSortMode === 'quality-then-scraper' && pluginStreams.length > 0) { const sortedPluginStreams = [...pluginStreams].sort((a, b) => { const titleA = (a.name || a.title || '').toLowerCase(); const titleB = (b.name || b.title || '').toLowerCase(); // Check for "Auto" quality - always prioritize it const isAutoA = /\b(auto|adaptive)\b/i.test(titleA); const isAutoB = /\b(auto|adaptive)\b/i.test(titleB); if (isAutoA && !isAutoB) return -1; // Auto comes first if (!isAutoA && isAutoB) return 1; // Auto comes first // If both are Auto or both are not Auto, continue with normal sorting // Helper function to extract quality as number const getQualityNumeric = (title: string | undefined): number => { if (!title) return 0; // Check for 4K first (treat as 2160p) if (/\b4k\b/i.test(title)) { return 2160; } const matchWithP = title.match(/(\d+)p/i); if (matchWithP) return parseInt(matchWithP[1], 10); const qualityPatterns = [ /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i ]; 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; }; const qualityA = getQualityNumeric(a.name || a.title); const qualityB = getQualityNumeric(b.name || b.title); // Sort by quality (highest first) if (qualityA !== qualityB) { return qualityB - qualityA; } // If quality is the same, sort by provider name, then stream name const providerA = a.addonId || a.addonName || ''; const providerB = b.addonId || b.addonName || ''; if (providerA !== providerB) { return providerA.localeCompare(providerB); } const nameA = (a.name || a.title || '').toLowerCase(); const nameB = (b.name || b.title || '').toLowerCase(); return nameA.localeCompare(nameB); }); // Add sorted plugin streams to the combined streams combinedStreams.push(...sortedPluginStreams); } else { // If quality sorting is disabled, just add plugin streams as-is combinedStreams.push(...pluginStreams); } return [{ title: 'Available Streams', addonId: 'grouped-all', data: combinedStreams, isEmptyDueToQualityFilter: false }]; } else { // Use separate sections for each provider (current behavior) return filteredEntries.map(([addonId, { addonName, streams: providerStreams }]) => { const isInstalledAddon = installedAddons.some(addon => addon.id === addonId); // Count original streams before filtering const originalCount = providerStreams.length; let filteredStreams = providerStreams; let isEmptyDueToQualityFilter = false; // Only apply quality filtering to plugins, NOT addons if (!isInstalledAddon) { filteredStreams = filterStreamsByQuality(providerStreams); isEmptyDueToQualityFilter = originalCount > 0 && filteredStreams.length === 0; } if (isEmptyDueToQualityFilter) { return { title: addonName, addonId, data: [{ isEmptyPlaceholder: true } as any], isEmptyDueToQualityFilter }; } let processedStreams = filteredStreams; // Apply quality sorting for plugins when enabled, but NOT for addons if (!isInstalledAddon && settings.streamSortMode === 'quality-then-scraper') { processedStreams = [...filteredStreams].sort((a, b) => { const titleA = (a.name || a.title || '').toLowerCase(); const titleB = (b.name || b.title || '').toLowerCase(); // Check for "Auto" quality - always prioritize it const isAutoA = /\b(auto|adaptive)\b/i.test(titleA); const isAutoB = /\b(auto|adaptive)\b/i.test(titleB); if (isAutoA && !isAutoB) return -1; // Auto comes first if (!isAutoA && isAutoB) return 1; // Auto comes first // If both are Auto or both are not Auto, continue with normal sorting // Helper function to extract quality as number const getQualityNumeric = (title: string | undefined): number => { if (!title) return 0; // Check for 4K first (treat as 2160p) if (/\b4k\b/i.test(title)) { return 2160; } const matchWithP = title.match(/(\d+)p/i); if (matchWithP) return parseInt(matchWithP[1], 10); const qualityPatterns = [ /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i ]; 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; }; const qualityA = getQualityNumeric(a.name || a.title); const qualityB = getQualityNumeric(b.name || b.title); // Sort by quality (highest first) if (qualityA !== qualityB) { return qualityB - qualityA; } // If quality is the same, sort by name/title const nameA = (a.name || a.title || '').toLowerCase(); const nameB = (b.name || b.title || '').toLowerCase(); return nameA.localeCompare(nameB); }); } return { title: addonName, addonId, data: processedStreams, isEmptyDueToQualityFilter: false }; }); } }, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, addonResponseOrder, settings.streamSortMode]); const episodeImage = useMemo(() => { if (episodeThumbnail) { if (episodeThumbnail.startsWith('http')) { return episodeThumbnail; } return tmdbService.getImageUrl(episodeThumbnail, 'original'); } if (!currentEpisode) return null; const hydratedStill = tmdbEpisodeOverride?.still_path; if (currentEpisode.still_path || hydratedStill) { if (currentEpisode.still_path.startsWith('http')) { return currentEpisode.still_path; } const path = currentEpisode.still_path || hydratedStill || ''; return tmdbService.getImageUrl(path, 'original'); } return metadata?.poster || null; }, [currentEpisode, metadata, episodeThumbnail, tmdbEpisodeOverride?.still_path]); // Effective TMDB fields for hero (series) const effectiveEpisodeVote = useMemo(() => { if (!currentEpisode) return 0; const v = (tmdbEpisodeOverride?.vote_average ?? currentEpisode.vote_average) || 0; return typeof v === 'number' ? v : Number(v) || 0; }, [currentEpisode, tmdbEpisodeOverride?.vote_average]); const effectiveEpisodeRuntime = useMemo(() => { if (!currentEpisode) return undefined as number | undefined; const r = (tmdbEpisodeOverride?.runtime ?? (currentEpisode as any).runtime) as number | undefined; return r; }, [currentEpisode, tmdbEpisodeOverride?.runtime]); // Prefetch hero/backdrop and title logo when StreamsScreen opens useEffect(() => { const urls: string[] = []; if (episodeImage && typeof episodeImage === 'string') urls.push(episodeImage); if (bannerImage && typeof bannerImage === 'string') urls.push(bannerImage); if (metadata && (metadata as any).logo && typeof (metadata as any).logo === 'string') { urls.push((metadata as any).logo as string); } // Deduplicate and prefetch Array.from(new Set(urls)).forEach(u => { RNImage.prefetch(u).catch(() => {}); }); }, [episodeImage, bannerImage, metadata]); const isLoading = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? loadingEpisodeStreams : loadingStreams; const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; // Determine extended loading phases const streamsEmpty = Object.keys(streams).length === 0; const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0; const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000); const showStillFetching = streamsEmpty && loadElapsed >= 10000; const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => { 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; // Clear scraper logo cache to free memory scraperLogoCache.clear(); scraperLogoCachePromise = null; }; }, []); return ( {Platform.OS !== 'ios' && ( {metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? 'Back to Episodes' : 'Back to Info'} )} {type === 'movie' && metadata && ( {metadata.logo && !movieLogoError ? ( setMovieLogoError(true)} /> ) : ( {metadata.name} )} )} {metadata?.videos && metadata.videos.length > 1 && selectedEpisode && ( {currentEpisode ? ( {currentEpisode.episodeString} {currentEpisode.name} {!!currentEpisode.overview && ( {currentEpisode.overview} )} {tmdbService.formatAirDate(currentEpisode.air_date)} {effectiveEpisodeVote > 0 && ( {effectiveEpisodeVote.toFixed(1)} )} {!!effectiveEpisodeRuntime && ( {effectiveEpisodeRuntime >= 60 ? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m` : `${effectiveEpisodeRuntime}m`} )} ) : ( // Placeholder to reserve space and avoid layout shift while loading )} )} {Object.keys(streams).length > 0 && ( )} {/* Active Scrapers Status */} {activeFetchingScrapers.length > 0 && ( Fetching from: {activeFetchingScrapers.map((scraperName, index) => ( ))} )} {/* Update the streams/loading state display logic */} { showNoSourcesError ? ( No streaming sources available Please add streaming sources in settings navigation.navigate('Addons')} > Add Sources ) : streamsEmpty ? ( showInitialLoading ? ( {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} ) : showStillFetching ? ( Still fetching streams… ) : ( // No streams and not loading = no streams available No streams available ) ) : ( // Show streams immediately when available, even if still loading others {/* Show autoplay loading overlay if waiting for autoplay */} {isAutoplayWaiting && !autoplayTriggered && ( Starting best stream... )} {sections.map((section, sectionIndex) => ( {/* Section Header */} {renderSectionHeader({ section })} {/* Stream Cards using FlatList */} {section.data && section.data.length > 0 ? ( { if (item && item.url) { return item.url; } return `empty-${sectionIndex}-${index}`; }} renderItem={({ item, index }) => ( handleStreamPress(item)} index={index} isLoading={false} statusMessage={undefined} theme={currentTheme} showLogos={settings.showScraperLogos} scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null} showAlert={(t, m) => openAlert(t, m)} parentTitle={metadata?.name} parentType={type as 'movie' | 'series'} parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined} parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined} parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined} parentPosterUrl={episodeImage || metadata?.poster || undefined} providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))} /> )} scrollEnabled={false} initialNumToRender={6} maxToRenderPerBatch={2} windowSize={3} removeClippedSubviews={true} showsVerticalScrollIndicator={false} getItemLayout={(data, index) => ({ length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom) offset: 78 * index, index, })} /> ) : ( // Empty section placeholder No streams available All streams were filtered by your quality settings )} ))} {/* Footer Loading */} {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && ( Loading more sources... )} )} {Platform.OS === 'ios' && ( setAlertVisible(false)} /> )} ); }; // Create a function to generate styles with the current theme colors const createStyles = (colors: any) => StyleSheet.create({ container: { flex: 1, backgroundColor: colors.darkBackground, // iOS-specific fixes for navigation transition glitches ...(Platform.OS === 'ios' && { // Ensure the background is properly rendered during transitions opacity: 1, // Prevent iOS from trying to optimize the background during transitions shouldRasterizeIOS: false, // Ensure the view is properly composited renderToHardwareTextureAndroid: false, }), }, backButtonContainer: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 2, pointerEvents: 'box-none', }, backButton: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 16, paddingVertical: 12, paddingTop: Platform.OS === 'android' ? 45 : 15, backgroundColor: 'transparent', }, backButtonText: { color: colors.highEmphasis, fontSize: 13, fontWeight: '600', }, streamsMainContent: { flex: 1, backgroundColor: colors.darkBackground, paddingTop: 12, zIndex: 1, // iOS-specific fixes for navigation transition glitches ...(Platform.OS === 'ios' && { // Ensure proper rendering during transitions opacity: 1, // Prevent iOS optimization that can cause glitches shouldRasterizeIOS: false, }), }, streamsMainContentMovie: { paddingTop: Platform.OS === 'android' ? 10 : 15, }, filterContainer: { paddingHorizontal: 12, paddingBottom: 8, }, filterScroll: { flexGrow: 0, }, filterChip: { backgroundColor: colors.elevation2, paddingHorizontal: 14, paddingVertical: 8, borderRadius: 16, marginRight: 8, borderWidth: 0, }, filterChipSelected: { backgroundColor: colors.primary, }, filterChipText: { color: colors.highEmphasis, fontWeight: '600', letterSpacing: 0.1, }, filterChipTextSelected: { color: colors.white, fontWeight: '700', }, streamsContent: { flex: 1, width: '100%', zIndex: 2, }, streamsContainer: { paddingHorizontal: 12, paddingBottom: 20, width: '100%', }, streamGroup: { marginBottom: 24, width: '100%', }, streamGroupTitle: { color: colors.highEmphasis, fontSize: 14, fontWeight: '700', marginBottom: 6, marginTop: 0, opacity: 0.9, backgroundColor: 'transparent', }, streamCard: { flexDirection: 'row', alignItems: 'flex-start', padding: 14, borderRadius: 12, marginBottom: 10, minHeight: 68, backgroundColor: colors.card, borderWidth: 0, width: '100%', zIndex: 1, shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 0, }, scraperLogoContainer: { width: 32, height: 32, marginRight: 12, justifyContent: 'center', alignItems: 'center', backgroundColor: colors.elevation2, borderRadius: 6, }, scraperLogo: { width: 24, height: 24, }, streamCardLoading: { opacity: 0.7, }, streamCardHighlighted: { backgroundColor: colors.elevation2, shadowOpacity: 0.18, }, 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: '700', marginBottom: 2, lineHeight: 20, color: colors.highEmphasis, letterSpacing: 0.1, }, streamAddonName: { fontSize: 12, lineHeight: 18, color: colors.mediumEmphasis, marginBottom: 6, }, streamMetaRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginBottom: 6, alignItems: 'center', }, chip: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 12, marginRight: 6, marginBottom: 6, backgroundColor: colors.elevation2, }, chipText: { color: colors.highEmphasis, fontSize: 11, fontWeight: '600', letterSpacing: 0.2, }, 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: 30, height: 30, borderRadius: 15, backgroundColor: colors.primary, 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, // Fixed height to prevent layout shift marginBottom: 0, position: 'relative', backgroundColor: colors.black, pointerEvents: 'box-none', }, streamsHeroBackground: { width: '100%', height: '100%', backgroundColor: colors.black, }, streamsHeroGradient: { ...StyleSheet.absoluteFillObject, 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', // chip background removed marginTop: 0, }, tmdbLogo: { width: 20, height: 14, }, streamsHeroRatingText: { color: colors.highEmphasis, 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: 140, backgroundColor: colors.darkBackground, pointerEvents: 'box-none', justifyContent: 'center', paddingTop: Platform.OS === 'android' ? 65 : 35, }, movieTitleContent: { width: '100%', height: 80, // Fixed height for consistent layout alignItems: 'center', justifyContent: 'center', }, movieLogo: { width: '100%', height: 80, // Fixed height to match content container maxWidth: width * 0.85, }, movieTitle: { color: colors.highEmphasis, fontSize: 28, fontWeight: '900', textAlign: 'center', letterSpacing: -0.5, paddingHorizontal: 20, }, streamsHeroRuntime: { flexDirection: 'row', alignItems: 'center', gap: 4, // chip background removed }, streamsHeroRuntimeText: { color: colors.mediumEmphasis, fontSize: 13, fontWeight: '600', }, sectionHeaderContainer: { paddingHorizontal: 12, paddingVertical: 8, }, sectionHeaderContent: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, sectionLoadingIndicator: { flexDirection: 'row', alignItems: 'center', }, sectionLoadingText: { marginLeft: 8, }, autoplayOverlay: { position: 'absolute', top: 0, left: 0, right: 0, backgroundColor: 'rgba(0,0,0,0.8)', padding: 16, alignItems: 'center', zIndex: 10, }, autoplayIndicator: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.elevation2, paddingHorizontal: 16, paddingVertical: 12, borderRadius: 8, }, autoplayText: { color: colors.primary, fontSize: 14, marginLeft: 8, fontWeight: '600', }, noStreamsSubText: { color: colors.mediumEmphasis, fontSize: 14, marginTop: 8, textAlign: 'center', }, addSourcesButton: { marginTop: 24, paddingHorizontal: 20, paddingVertical: 10, backgroundColor: colors.primary, borderRadius: 8, }, addSourcesButtonText: { color: colors.white, fontSize: 14, fontWeight: '600', }, activeScrapersContainer: { paddingHorizontal: 16, paddingVertical: 8, backgroundColor: 'transparent', marginHorizontal: 16, marginBottom: 4, }, activeScrapersTitle: { color: colors.mediumEmphasis, fontSize: 12, fontWeight: '500', marginBottom: 6, opacity: 0.8, }, activeScrapersRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, }, activeScraperChip: { backgroundColor: colors.elevation2, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, borderWidth: 0, }, activeScraperText: { color: colors.mediumEmphasis, fontSize: 11, fontWeight: '400', }, emptySectionContainer: { padding: 16, alignItems: 'center', justifyContent: 'center', minHeight: 80, }, emptySectionContent: { alignItems: 'center', justifyContent: 'center', }, emptySectionTitle: { fontSize: 14, fontWeight: '600', marginTop: 8, textAlign: 'center', }, emptySectionSubtitle: { fontSize: 12, marginTop: 4, textAlign: 'center', lineHeight: 16, }, }); export default memo(StreamsScreen);