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, Clipboard, Image as RNImage, } from 'react-native'; 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 Animated, { FadeIn, FadeOut, 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'); // Cache for scraper logos to avoid repeated async calls const scraperLogoCache = new Map(); let scraperLogoCachePromise: Promise | null = null; // Extracted Components const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos }: { stream: Stream; onPress: () => void; index: number; isLoading?: boolean; statusMessage?: string; theme: any; showLogos?: boolean; }) => { // Handle long press to copy stream URL to clipboard const handleLongPress = useCallback(async () => { if (stream.url) { try { await Clipboard.setString(stream.url); Alert.alert( 'Copied!', 'Stream URL has been copied to clipboard.', [{ text: 'OK' }] ); } catch (error) { // Fallback: show URL in alert if clipboard fails Alert.alert( 'Stream URL', stream.url, [{ text: 'OK' }] ); } } }, [stream.url]); 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]); // Get scraper logo for local scrapers using cache const [scraperLogo, setScraperLogo] = React.useState(() => { const scraperId = stream.addonId || stream.addon; return scraperId ? scraperLogoCache.get(scraperId) || null : null; }); React.useEffect(() => { const scraperId = stream.addonId || stream.addon; if (!scraperId) return; // Check cache first const cachedLogo = scraperLogoCache.get(scraperId); if (cachedLogo) { setScraperLogo(cachedLogo); return; } // If not in cache, fetch asynchronously let isMounted = true; const getScraperLogo = async () => { try { const availableScrapers = await localScraperService.getAvailableScrapers(); const scraper = availableScrapers.find(s => s.id === scraperId); if (scraper && scraper.logo && isMounted) { // Cache the logo for future use scraperLogoCache.set(scraperId, scraper.logo); setScraperLogo(scraper.logo); } } catch (error) { // Silently fail if we can't get scraper info } }; getScraperLogo(); return () => { isMounted = false; }; }, [stream.addonId, stream.addon]); 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 )} ); }); 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]); const pulseValue = useSharedValue(0.6); useEffect(() => { const startPulse = () => { pulseValue.value = withTiming(1, { duration: 1200 }, () => { pulseValue.value = withTiming(0.6, { duration: 1200 }, () => { runOnJS(startPulse)(); }); }); }; const timer = setTimeout(startPulse, delay); return () => { clearTimeout(timer); cancelAnimation(pulseValue); }; }, [delay]); const animatedStyle = useAnimatedStyle(() => { return { opacity: pulseValue.value }; }); 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 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 ref to prevent excessive updates const isMounted = useRef(true); const loadStartTimeRef = useRef(0); const hasDoneInitialLoadRef = useRef(false); // 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(() => { console.log('[StreamsScreen] Received thumbnail from params:', episodeThumbnail); }, [episodeThumbnail]); // 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, } = 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()); // 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; } }>({}); // 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); // Preload scraper logos to cache for faster display React.useEffect(() => { const preloadScraperLogos = async () => { if (scraperLogoCachePromise) return; // Already loading scraperLogoCachePromise = (async () => { try { const availableScrapers = await localScraperService.getAvailableScrapers(); availableScrapers.forEach(scraper => { if (scraper.logo && scraper.id) { scraperLogoCache.set(scraper.id, scraper.logo); } }); } catch (error) { // Silently fail } })(); }; preloadScraperLogos(); }, []); // Monitor streams loading and update available providers immediately useEffect(() => { // Skip processing if component is unmounting if (!isMounted.current) return; const currentStreamsData = type === 'series' ? episodeStreams : groupedStreams; // 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]); return newProviders; }); } // Update loading states for individual providers const expectedProviders = ['stremio']; const now = Date.now(); setLoadingProviders(prevLoading => { const newLoading = { ...prevLoading }; expectedProviders.forEach(providerId => { // Provider is loading if overall loading is true OR if it doesn't have streams yet const hasStreams = currentStreamsData[providerId] && currentStreamsData[providerId].streams && currentStreamsData[providerId].streams.length > 0; newLoading[providerId] = (loadingStreams || loadingEpisodeStreams) && !hasStreams; }); return newLoading; }); }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type]); // 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 = type === 'series' ? 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]); // Update useEffect to check for sources useEffect(() => { const checkProviders = async () => { // Check for Stremio addons const hasStremioProviders = await stremioService.hasStreamProviders(); // Check for local scrapers (only if enabled in settings) const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers(); // We have providers if we have either Stremio addons OR enabled local scrapers const hasProviders = hasStremioProviders || hasLocalScrapers; if (!isMounted.current) return; setHasStreamProviders(hasProviders); setHasStremioStreamProviders(hasStremioProviders); if (!hasProviders) { const timer = setTimeout(() => { if (isMounted.current) setShowNoSourcesError(true); }, 500); return () => clearTimeout(timer); } else { if (type === 'series' && episodeId) { logger.log(`🎬 Loading episode streams for: ${episodeId}`); setLoadingProviders({ 'stremio': true }); setSelectedEpisode(episodeId); setStreamsLoadStart(Date.now()); loadEpisodeStreams(episodeId); } else if (type === 'movie') { logger.log(`🎬 Loading movie streams for: ${id}`); setStreamsLoadStart(Date.now()); 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'); } } } }; checkProviders(); }, [type, id, episodeId, settings.autoplayBestStream, fromPlayer]); 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: 100 }); heroScale.value = withTiming(0.95, { duration: 100 }); filterOpacity.value = withTiming(0, { duration: 100 }); }; cleanup(); if (type === 'series') { // Reset stack to ensure there is always a screen to go back to from Metadata (navigation as any).reset({ index: 1, routes: [ { name: 'MainTabs' }, { name: 'Metadata', params: { id, type } } ] }); return; } if (navigation.canGoBack()) { navigation.goBack(); } else { (navigation as any).navigate('MainTabs'); } }, [navigation, headerOpacity, heroScale, filterOpacity, type, id]); 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 => { // Create a case-insensitive regex pattern for the quality 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]); // Helper function to sort streams based on user preference const sortStreams = useCallback((streams: Stream[]) => { const installedAddons = stremioService.getInstalledAddons(); // 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 = (stream: Stream): number => { const addonId = stream.addonId || stream.addonName || ''; 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 }; return [...streams].sort((a, b) => { const qualityA = getQualityNumeric(a.name || a.title); const qualityB = getQualityNumeric(b.name || b.title); const providerPriorityA = getProviderPriority(a); const providerPriorityB = getProviderPriority(b); const isCachedA = a.behaviorHints?.cached || false; const isCachedB = b.behaviorHints?.cached || false; // Always prioritize cached/debrid streams first if (isCachedA !== isCachedB) { return isCachedA ? -1 : 1; } if (settings.streamSortMode === 'quality-then-scraper') { // Sort by quality first, then by provider if (qualityA !== qualityB) { return qualityB - qualityA; // Higher quality first } if (providerPriorityA !== providerPriorityB) { return providerPriorityB - providerPriorityA; // Better provider first } } else { // Default: Sort by provider first, then by quality if (providerPriorityA !== providerPriorityB) { return providerPriorityB - providerPriorityA; // Better provider first } if (qualityA !== qualityB) { return qualityB - qualityA; // Higher quality first } } return 0; }); }, [settings.excludedQualities, settings.streamSortMode]); // 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; isDebrid: boolean; isCached: boolean; }> = []; 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); const isDebrid = stream.behaviorHints?.cached || false; const isCached = isDebrid; allStreams.push({ stream, quality, providerPriority, isDebrid, isCached, }); }); }); if (allStreams.length === 0) return null; // Sort streams by multiple criteria (best first) allStreams.sort((a, b) => { // 1. Prioritize cached/debrid streams if (a.isCached !== b.isCached) { return a.isCached ? -1 : 1; } // 2. Prioritize higher quality if (a.quality !== b.quality) { return b.quality - a.quality; } // 3. 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}, Cached: ${allStreams[0].isCached})`); 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 }) => { // 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 streamName = stream.name || stream.title || 'Unnamed Stream'; const streamProvider = stream.addonId || stream.addonName || stream.name; // Determine if we should force VLC on iOS based on provider-declared formats (e.g., MKV) let forceVlc = !!options?.forceVlc; try { const providerId = stream.addonId || (stream as any).addon; if (Platform.OS === 'ios' && providerId && !forceVlc) { forceVlc = await localScraperService.supportsFormat(providerId, 'mkv'); logger.log(`[StreamsScreen] Provider '${providerId}' MKV support -> ${forceVlc}`); } } catch (e) { logger.warn('[StreamsScreen] MKV support detection failed:', e); } // Add pre-navigation orientation lock to reduce glitch try { await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); } catch (e) { logger.warn('[StreamsScreen] Pre-navigation orientation lock failed:', e); } // Small delay to allow orientation to settle await new Promise(res => setTimeout(res, Platform.OS === 'ios' ? 120 : 60)); // 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) 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: streamProvider, streamName: streamName, // Always prefer stream.headers; player will use these for requests headers: options?.headers || stream.headers || undefined, // Force VLC for providers that declare MKV format support on iOS forceVlc, id, type, episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, imdbId: imdbId || undefined, availableStreams: streamsToPass, backdrop: bannerImage || undefined, }); }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage]); // Update handleStreamPress const handleStreamPress = useCallback(async (stream: Stream) => { try { if (stream.url) { // If provider declares MKV support, force the in-app VLC-based player on iOS try { const providerId = stream.addonId || (stream as any).addon; if (Platform.OS === 'ios' && providerId) { const providerRequiresVlc = await localScraperService.supportsFormat(providerId, 'mkv'); if (providerRequiresVlc) { logger.log(`[StreamsScreen] Forcing in-app VLC for provider '${providerId}' on iOS due to MKV support`); navigateToPlayer(stream); return; } } } catch (err) { logger.warn('[StreamsScreen] MKV pre-check failed:', err); } // On iOS, for installed addons where URL may not include .mkv, send a HEAD request // to detect MKV via Content-Type before opening the player if (Platform.OS === 'ios') { const lowerUrl = (stream.url || '').toLowerCase(); const isMkvByPath = lowerUrl.includes('.mkv') || /[?&]ext=mkv\b/.test(lowerUrl) || /format=mkv\b/.test(lowerUrl) || /container=mkv\b/.test(lowerUrl); const isHttp = lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); if (!isMkvByPath && isHttp) { try { const mkvDetected = await (async () => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 4000); try { const res = await fetch(stream.url, { method: 'HEAD', // Pass along any known headers to improve odds of correct response headers: (stream.headers as any) || undefined, signal: controller.signal as any, } as any); const contentType = res.headers.get('content-type') || ''; return typeof contentType === 'string' && /matroska|x-matroska/i.test(contentType); } catch (_e) { return false; } finally { clearTimeout(timeout); } })(); if (mkvDetected) { const mergedHeaders = { ...(stream.headers || {}), 'Content-Type': 'video/x-matroska', } as Record; logger.log('[StreamsScreen] HEAD detected MKV via Content-Type, forcing in-app VLC on iOS'); navigateToPlayer(stream, { forceVlc: true, headers: mergedHeaders }); return; } } catch (e) { logger.warn('[StreamsScreen] HEAD 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; } 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 VideoPlayerService to show the Android app chooser const success = await VideoPlayerService.playVideo(stream.url, { useExternalPlayer: true, title: metadata?.name || 'Video', episodeTitle: type === 'series' ? currentEpisode?.name : undefined, episodeNumber: type === 'series' && currentEpisode ? `S${currentEpisode.season_number}E${currentEpisode.episode_number}` : undefined, }); if (!success) { console.log('VideoPlayerService failed, falling back to 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]); // Ensure portrait when returning to this screen on iOS useFocusEffect( useCallback(() => { if (Platform.OS === 'ios') { ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {}); } return () => {}; }, []) ); // Autoplay effect - triggers immediately when streams are available and autoplay is enabled useEffect(() => { if ( settings.autoplayBestStream && !autoplayTriggered && isAutoplayWaiting ) { const streams = type === 'series' ? 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 = 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 ) ]); // 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 = type === 'series' ? 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 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; }); // Check if we should group all streams under one section if (settings.streamDisplayMode === 'grouped') { // Separate streams by type: installed addons vs plugins const addonStreams: Stream[] = []; const pluginStreams: Stream[] = []; const addonNames: string[] = []; const pluginNames: string[] = []; filteredEntries.forEach(([addonId, { addonName, streams: providerStreams }]) => { const isInstalledAddon = installedAddons.some(addon => addon.id === addonId); // Apply quality filtering and sorting to streams const filteredStreams = filterStreamsByQuality(providerStreams); const sortedStreams = sortStreams(filteredStreams); if (isInstalledAddon) { addonStreams.push(...sortedStreams); if (!addonNames.includes(addonName)) { addonNames.push(addonName); } } else { pluginStreams.push(...sortedStreams); if (!pluginNames.includes(addonName)) { pluginNames.push(addonName); } } }); const sections = []; if (addonStreams.length > 0) { // Apply final sorting to the combined addon streams for quality-first mode const finalSortedAddonStreams = settings.streamSortMode === 'quality-then-scraper' ? sortStreams(addonStreams) : addonStreams; sections.push({ title: addonNames.join(', '), addonId: 'grouped-addons', data: finalSortedAddonStreams }); } if (pluginStreams.length > 0) { // Apply final sorting to the combined plugin streams for quality-first mode const finalSortedPluginStreams = settings.streamSortMode === 'quality-then-scraper' ? sortStreams(pluginStreams) : pluginStreams; sections.push({ title: localScraperService.getRepositoryName(), addonId: 'grouped-plugins', data: finalSortedPluginStreams }); } return sections; } else { // Use separate sections for each provider (current behavior) return filteredEntries.map(([addonId, { addonName, streams: providerStreams }]) => { // Apply quality filtering and sorting to streams const filteredStreams = filterStreamsByQuality(providerStreams); const sortedStreams = sortStreams(filteredStreams); return { title: addonName, addonId, data: sortedStreams }; }); } }, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, sortStreams]); 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 = type === 'series' ? loadingEpisodeStreams : loadingStreams; const streams = type === 'series' ? 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 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} showLogos={settings.showScraperLogos} /> ); }, [handleStreamPress, currentTheme, settings.showScraperLogos]); 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; }; }, []); return ( {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)} {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... )} item.url || `${item.name}-${item.title}`} renderItem={renderItem} renderSectionHeader={renderSectionHeader} stickySectionHeadersEnabled={false} initialNumToRender={6} maxToRenderPerBatch={3} windowSize={4} removeClippedSubviews={false} contentContainerStyle={styles.streamsContainer} style={styles.streamsContent} showsVerticalScrollIndicator={false} bounces={true} overScrollMode="never" ListFooterComponent={ (loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders ? ( 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, 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, }, 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.1, shadowRadius: 6, shadowOffset: { width: 0, height: 2 }, elevation: 2, }, 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: '100%', alignItems: 'center', justifyContent: 'center', }, movieLogo: { width: '100%', height: '100%', 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', }, transitionOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: colors.darkBackground, justifyContent: 'center', alignItems: 'center', zIndex: 9999, }, 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', }, }); export default memo(StreamsScreen);