diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 6191ca1..d5309ec 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -1,2872 +1,14 @@ -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 FastImage from '@d11/react-native-fast-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, IMDbRatings } from '../services/tmdbService'; -import { stremioService } from '../services/stremioService'; -import { localScraperService } from '../services/pluginService'; -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 { useToast } from '../contexts/ToastContext'; -import { useDownloads } from '../contexts/DownloadsContext'; -import { streamCacheService } from '../services/streamCacheService'; -import { useDominantColor } from '../hooks/useDominantColor'; -import { PaperProvider } from 'react-native-paper'; -import { BlurView as ExpoBlurView } from 'expo-blur'; -import TabletStreamsLayout from '../components/TabletStreamsLayout'; -import ProviderFilter from '../components/ProviderFilter'; -import PulsingChip from '../components/PulsingChip'; -import StreamCard from '../components/StreamCard'; -import AnimatedImage from '../components/AnimatedImage'; -import AnimatedText from '../components/AnimatedText'; -import AnimatedView from '../components/AnimatedView'; - -// Lazy-safe community blur import for Android -let AndroidBlurView: any = null; -if (Platform.OS === 'android') { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - AndroidBlurView = require('@react-native-community/blur').BlurView; - } catch (_) { - AndroidBlurView = null; - } -} - -const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; -const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png'; -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 - -// Extracted Components - -const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => { - const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); - - return ( - - {text} - - ); -}); - - - -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(); - const { showSuccess, showInfo } = useToast(); - - // Add dimension listener and tablet detection - // Use a ref to track previous dimensions to avoid unnecessary re-renders - const [dimensions, setDimensions] = useState(Dimensions.get('window')); - const prevDimensionsRef = useRef({ width: dimensions.width, height: dimensions.height }); - - useEffect(() => { - const subscription = Dimensions.addEventListener('change', ({ window }) => { - // Only update state if dimensions actually changed (with 1px tolerance) - const widthChanged = Math.abs(window.width - prevDimensionsRef.current.width) > 1; - const heightChanged = Math.abs(window.height - prevDimensionsRef.current.height) > 1; - - if (widthChanged || heightChanged) { - prevDimensionsRef.current = { width: window.width, height: window.height }; - setDimensions(window); - } - }); - return () => subscription?.remove(); - }, []); - - // Memoize tablet detection to prevent recalculation on every render - const deviceWidth = dimensions.width; - const isTablet = useMemo(() => deviceWidth >= 768, [deviceWidth]); - - // 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, settings.enrichMetadataWithTMDB]); - 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 - // Use a ref to track the previous providers to avoid unnecessary state updates - const prevProvidersRef = useRef>(new Set()); - - 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(', ')}`); - - // Check if we actually have new providers before triggering state update - const hasNewProviders = providersWithStreams.some( - provider => !prevProvidersRef.current.has(provider) - ); - - if (hasNewProviders) { - setAvailableProviders(prevProviders => { - const newProviders = new Set([...prevProviders, ...providersWithStreams]); - // Update ref to track current providers - prevProvidersRef.current = newProviders; - 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 providerExists = currentStreamsData[providerId]; - const hasStreams = providerExists && - currentStreamsData[providerId].streams && - currentStreamsData[providerId].streams.length > 0; - - // Stop loading if: - // 1. Provider exists (completed) and has streams, OR - // 2. Provider exists (completed) but has 0 streams, OR - // 3. Overall loading is false - const shouldStopLoading = providerExists || !(loadingStreams || loadingEpisodeStreams); - const value = !shouldStopLoading; - - 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 that support this content type (including embedded streams) - const hasStremioProviders = await stremioService.hasStreamProviders(type); - if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders, 'for type:', type); - - // 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]); - - // Helper function to filter streams by language exclusions - const filterStreamsByLanguage = useCallback((streams: Stream[]) => { - if (!settings.excludedLanguages || settings.excludedLanguages.length === 0) { - console.log('๐Ÿ” [filterStreamsByLanguage] No excluded languages, returning all streams'); - return streams; - } - - console.log('๐Ÿ” [filterStreamsByLanguage] Filtering with excluded languages:', settings.excludedLanguages); - - // Log first few stream details to see what fields contain language info - if (streams.length > 0) { - console.log('๐Ÿ” [filterStreamsByLanguage] Sample stream details:', streams.slice(0, 3).map(s => ({ - title: s.title || s.name, - description: s.description?.substring(0, 100), - name: s.name, - addonName: s.addonName, - addonId: s.addonId - }))); - } - - const filtered = streams.filter(stream => { - const streamName = stream.name || ''; // This contains the language info like "VIDEASY Gekko (Latin) - Adaptive" - const streamTitle = stream.title || ''; - const streamDescription = stream.description || ''; - const searchText = `${streamName} ${streamTitle} ${streamDescription}`.toLowerCase(); - - // Check if any excluded language is found in the stream title or description - const hasExcludedLanguage = settings.excludedLanguages.some(excludedLanguage => { - const langLower = excludedLanguage.toLowerCase(); - - // Check multiple variations of the language name - const variations = [langLower]; - - // Add common variations for each language - if (langLower === 'latin') { - variations.push('latino', 'latina', 'lat'); - } else if (langLower === 'spanish') { - variations.push('espaรฑol', 'espanol', 'spa'); - } else if (langLower === 'german') { - variations.push('deutsch', 'ger'); - } else if (langLower === 'french') { - variations.push('franรงais', 'francais', 'fre'); - } else if (langLower === 'portuguese') { - variations.push('portuguรชs', 'portugues', 'por'); - } else if (langLower === 'italian') { - variations.push('ita'); - } else if (langLower === 'english') { - variations.push('eng'); - } else if (langLower === 'japanese') { - variations.push('jap'); - } else if (langLower === 'korean') { - variations.push('kor'); - } else if (langLower === 'chinese') { - variations.push('chi', 'cn'); - } else if (langLower === 'arabic') { - variations.push('ara'); - } else if (langLower === 'russian') { - variations.push('rus'); - } else if (langLower === 'turkish') { - variations.push('tur'); - } else if (langLower === 'hindi') { - variations.push('hin'); - } - - const matches = variations.some(variant => searchText.includes(variant)); - - if (matches) { - console.log(`๐Ÿ” [filterStreamsByLanguage] โœ• Excluding stream with ${excludedLanguage}:`, streamName.substring(0, 100)); - } - return matches; - }); - - // Return true to keep the stream (if it doesn't have excluded language) - return !hasExcludedLanguage; - }); - - console.log(`๐Ÿ” [filterStreamsByLanguage] Filtered ${streams.length} โ†’ ${filtered.length} streams`); - return filtered; - }, [settings.excludedLanguages]); - - // 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 and language filtering to streams before processing - const qualityFiltered = filterStreamsByQuality(streams); - const filteredStreams = filterStreamsByLanguage(qualityFiltered); - - 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); - // IMDb ratings for episodes - using a map for O(1) lookups - const [imdbRatingsMap, setImdbRatingsMap] = useState<{ [key: string]: number }>({}); - - 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]); - - // Fetch IMDb ratings for the show - useEffect(() => { - const fetchIMDbRatings = async () => { - try { - if (type !== 'series' && type !== 'other') return; - if (!id || !currentEpisode) 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; - - // Fetch IMDb ratings for all seasons - const ratings = await tmdbService.getIMDbRatings(tmdbShowId); - - if (ratings) { - // Create a lookup map for O(1) access: key format "season:episode" -> rating - const ratingsMap: { [key: string]: number } = {}; - ratings.forEach(season => { - if (season.episodes) { - season.episodes.forEach(episode => { - const key = `${episode.season_number}:${episode.episode_number}`; - if (episode.vote_average) { - ratingsMap[key] = episode.vote_average; - } - }); - } - }); - - setImdbRatingsMap(ratingsMap); - } - } catch (err) { - logger.error('[StreamsScreen] Failed to fetch IMDb ratings:', err); - } - }; - - fetchIMDbRatings(); - }, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]); - - const navigateToPlayer = useCallback(async (stream: Stream, options?: { headers?: Record }) => { - // Filter headers for Vidrock - only send essential headers - // Filter headers for Vidrock - only send essential headers - // Filter headers for Vidrock - only send essential headers - const filterHeadersForVidrock = (headers: Record | undefined): Record | undefined => { - if (!headers) return undefined; - - // Only keep essential headers for Vidrock - const essentialHeaders: Record = {}; - // @ts-ignore - if (headers['User-Agent']) essentialHeaders['User-Agent'] = headers['User-Agent']; - // @ts-ignore - if (headers['Referer']) essentialHeaders['Referer'] = headers['Referer']; - // @ts-ignore - if (headers['Origin']) essentialHeaders['Origin'] = headers['Origin']; - - return Object.keys(essentialHeaders).length > 0 ? essentialHeaders : undefined; - }; - - // @ts-ignore - const finalHeaders = filterHeadersForVidrock(options?.headers || stream.headers); - - // Add logging here - console.log('[StreamsScreen] Navigating to player with headers:', { - streamHeaders: stream.headers, - optionsHeaders: options?.headers, - filteredHeaders: finalHeaders, - streamUrl: stream.url, - streamName: stream.name || stream.title - }); - - // 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; - - // Save stream to cache for future use - try { - const episodeId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined; - const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; - const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; - const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; - - await streamCacheService.saveStreamToCache( - id, - type, - stream, - metadata, - episodeId, - season, - episode, - episodeTitle, - imdbId || undefined, - settings.streamCacheTTL - ); - } catch (error) { - logger.warn('[StreamsScreen] Failed to save stream to cache:', error); - } - - // 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'; - - // @ts-ignore - navigation.navigate(playerRoute as any, { - uri: stream.url as any, - 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, - // Use filtered headers for Vidrock compatibility - headers: finalHeaders, - id, - type, - episodeId: (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined, - imdbId: imdbId || undefined, - availableStreams: streamsToPass, - backdrop: bannerImage, - // 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 (typeof stream.url === 'string' && 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; - - case 'infuse_livecontainer': - const infuseUrls = [ - `infuse://x-callback-url/play?url=${streamUrl}`, - `infuse://play?url=${streamUrl}`, - `infuse://${streamUrl}` - ]; - externalPlayerUrls = infuseUrls.map(infuseUrl => { - const encoded = Buffer.from(infuseUrl).toString('base64'); - return `livecontainer://open-url?url=${encoded}`; - }); - 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 - if (__DEV__) console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`); - // Try direct URL as last resort - // @ts-ignore - 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 = typeof stream.url === 'string' && 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 proper rendering when returning to this screen - useFocusEffect( - useCallback(() => { - if (Platform.OS === 'ios') { - // 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(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; - - // Only include providers that actually have streams - const providersWithStreams = Object.keys(streams).filter(key => { - const providerData = streams[key]; - if (!providerData || !providerData.streams) { - return false; - } - - // Only show providers (addons or plugins) if they have actual streams - return providerData.streams.length > 0; - }); - - const allProviders = new Set([ - ...Array.from(availableProviders).filter((provider: string) => - streams[provider] && streams[provider].streams && streams[provider].streams.length > 0 - ), - ...providersWithStreams - ]); - - // 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 with streams - 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: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null> = useMemo(() => { - const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; - const installedAddons = stremioService.getInstalledAddons(); - - console.log('๐Ÿ” [StreamsScreen] Sections debug:', { - streamsKeys: Object.keys(streams), - installedAddons: installedAddons.map(a => ({ id: a.id, name: a.name })), - selectedProvider, - streamDisplayMode: settings.streamDisplayMode, - streamsData: Object.entries(streams).map(([key, data]) => ({ - provider: key, - addonName: data.addonName, - streamCount: data.streams?.length || 0 - })) - }); - - // 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; - }); - - console.log('๐Ÿ” [StreamsScreen] Filtered entries:', { - filteredCount: filteredEntries.length, - filteredEntries: filteredEntries.map(([addonId, data]) => ({ - addonId, - addonName: data.addonName, - streamCount: data.streams?.length || 0 - })) - }); - - const sortedEntries = filteredEntries.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; - - sortedEntries.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 and language filtering and sorting - const qualityFiltered = filterStreamsByQuality(providerStreams); - const filteredStreams = filterStreamsByLanguage(qualityFiltered); - - if (filteredStreams.length > 0) { - pluginStreams.push(...filteredStreams); - } - } - }); - - const totalStreamsCount = addonStreams.length + pluginStreams.length; - const isEmptyDueToQualityFilter = totalOriginalCount > 0 && totalStreamsCount === 0; - - if (isEmptyDueToQualityFilter) { - return []; // Return empty array instead of showing placeholder - } - - // 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); - } - - const result = [{ - title: 'Available Streams', - addonId: 'grouped-all', - data: combinedStreams, - isEmptyDueToQualityFilter: false - }]; - - console.log('๐Ÿ” [StreamsScreen] Grouped mode result:', { - resultCount: result.length, - combinedStreamsCount: combinedStreams.length, - addonStreamsCount: addonStreams.length, - pluginStreamsCount: pluginStreams.length, - totalOriginalCount - }); - - return result; - } else { - // Use separate sections for each provider (current behavior) - return sortedEntries.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 and language filtering to plugins, NOT addons - if (!isInstalledAddon) { - console.log('๐Ÿ” [StreamsScreen] Applying quality and language filters to plugin:', { - addonId, - addonName, - originalCount, - excludedQualities: settings.excludedQualities, - excludedLanguages: settings.excludedLanguages - }); - const qualityFiltered = filterStreamsByQuality(providerStreams); - filteredStreams = filterStreamsByLanguage(qualityFiltered); - isEmptyDueToQualityFilter = originalCount > 0 && filteredStreams.length === 0; - console.log('๐Ÿ” [StreamsScreen] Quality and language filter result:', { - addonId, - filteredCount: filteredStreams.length, - isEmptyDueToQualityFilter - }); - } else { - console.log('๐Ÿ” [StreamsScreen] Skipping quality and language filters for addon:', { - addonId, - addonName, - originalCount - }); - } - - // Exclude providers with no streams at all - if (filteredStreams.length === 0) { - return null; // Return null to exclude this section completely - } - - if (isEmptyDueToQualityFilter) { - return null; // Return null to exclude this section completely - } - - 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); - }); - } - - const result = { - title: addonName, - addonId, - data: processedStreams, - isEmptyDueToQualityFilter: false - }; - - console.log('๐Ÿ” [StreamsScreen] Individual mode result:', { - addonId, - addonName, - processedStreamsCount: processedStreams.length, - originalCount, - isInstalledAddon - }); - - return result; - }).filter(Boolean); // Filter out null values - } - }, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, addonResponseOrder, settings.streamSortMode, selectedEpisode, metadata]); - - // Debug log for sections result - React.useEffect(() => { - console.log('๐Ÿ” [StreamsScreen] Final sections:', { - sectionsCount: sections.length, - sections: sections.filter(Boolean).map(s => ({ - title: s!.title, - addonId: s!.addonId, - dataCount: s!.data?.length || 0, - isEmptyDueToQualityFilter: s!.isEmptyDueToQualityFilter - })) - }); - }, [sections]); - - 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'); - } - // No poster fallback - return null; - }, [currentEpisode, metadata, episodeThumbnail, tmdbEpisodeOverride?.still_path, settings.enrichMetadataWithTMDB]); - - // Helper function to get IMDb rating for an episode - O(1) lookup using map - const getIMDbRating = useCallback((seasonNumber: number, episodeNumber: number): number | null => { - const key = `${seasonNumber}:${episodeNumber}`; - const rating = imdbRatingsMap[key]; - return rating ?? null; - }, [imdbRatingsMap]); - - // Effective rating for hero (series) - prioritize IMDb, fallback to TMDB - const effectiveEpisodeVote = useMemo(() => { - if (!currentEpisode) return 0; - - // Try IMDb rating first - const imdbRating = getIMDbRating(currentEpisode.season_number, currentEpisode.episode_number); - if (imdbRating !== null) { - return imdbRating; - } - - // Fallback to TMDB - const v = (tmdbEpisodeOverride?.vote_average ?? currentEpisode.vote_average) || 0; - return typeof v === 'number' ? v : Number(v) || 0; - }, [currentEpisode, tmdbEpisodeOverride?.vote_average, getIMDbRating]); - - // Check if current episode has IMDb rating - const hasIMDbRating = useMemo(() => { - if (!currentEpisode) return false; - return getIMDbRating(currentEpisode.season_number, currentEpisode.episode_number) !== null; - }, [currentEpisode, getIMDbRating]); - - 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]); - - // Mobile backdrop source selection logic - const mobileBackdropSource = useMemo(() => { - // For series episodes: prioritize episodeImage, fallback to bannerImage - if (type === 'series' || (type === 'other' && selectedEpisode)) { - if (episodeImage) { - return episodeImage; - } - if (bannerImage) { - return bannerImage; - } - } - - // For movies: prioritize bannerImage - if (type === 'movie') { - if (bannerImage) { - return bannerImage; - } - } - - // For other types or when no specific image available - return bannerImage || episodeImage; - }, [type, selectedEpisode, episodeImage, bannerImage]); - - // Backdrop source for color extraction - only episodes, not movies - const colorExtractionSource = useMemo(() => { - // Only extract colors if backdrop is enabled - if (!settings.enableStreamsBackdrop) { - return null; - } - - if (type === 'series' || (type === 'other' && selectedEpisode)) { - // Only use episodeImage - don't fallback to bannerImage - // This ensures we get episode-specific colors, not show-wide colors - return episodeImage || null; - } - // Return null for movies - no color extraction - return null; - }, [type, selectedEpisode, episodeImage, settings.enableStreamsBackdrop]); - - // Extract dominant color from backdrop for gradient - const { dominantColor } = useDominantColor(colorExtractionSource); - - // 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]); - - // Helper to create gradient colors from dominant color - const createGradientColors = useCallback((baseColor: string | null): [string, string, string, string, string] => { - // Always use black gradient when backdrop is enabled - if (settings.enableStreamsBackdrop) { - return ['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.6)', 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)']; - } - - // When backdrop is disabled, use theme background gradient - const themeBg = colors.darkBackground; - - // Handle hex color format (e.g., #1a1a1a) - if (themeBg.startsWith('#')) { - const r = parseInt(themeBg.substr(1, 2), 16); - const g = parseInt(themeBg.substr(3, 2), 16); - const b = parseInt(themeBg.substr(5, 2), 16); - return [ - `rgba(${r},${g},${b},0)`, - `rgba(${r},${g},${b},0.3)`, - `rgba(${r},${g},${b},0.6)`, - `rgba(${r},${g},${b},0.85)`, - `rgba(${r},${g},${b},0.95)`, - ]; - } - - // Handle rgb color format (e.g., rgb(26, 26, 26)) - const rgbMatch = themeBg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (rgbMatch) { - const [, r, g, b] = rgbMatch; - return [ - `rgba(${r},${g},${b},0)`, - `rgba(${r},${g},${b},0.3)`, - `rgba(${r},${g},${b},0.6)`, - `rgba(${r},${g},${b},0.85)`, - `rgba(${r},${g},${b},0.95)`, - ]; - } - - if (!baseColor || baseColor === '#1a1a1a') { - // Fallback to black gradient with stronger bottom edge - return ['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.6)', 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)']; - } - - // Convert hex to RGB - const r = parseInt(baseColor.substr(1, 2), 16); - const g = parseInt(baseColor.substr(3, 2), 16); - const b = parseInt(baseColor.substr(5, 2), 16); - - // Create gradient stops with much stronger opacity at bottom - return [ - `rgba(${r},${g},${b},0)`, - `rgba(${r},${g},${b},0.3)`, - `rgba(${r},${g},${b},0.6)`, - `rgba(${r},${g},${b},0.85)`, - `rgba(${r},${g},${b},0.95)`, - ]; - }, [settings.enableStreamsBackdrop, colors.darkBackground]); - - const gradientColors = useMemo(() => - createGradientColors(dominantColor), - [dominantColor, createGradientColors] - ); - - 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 || - Object.values(streams).every(provider => !provider.streams || provider.streams.length === 0); - const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0; - const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000); - const showStillFetching = streamsEmpty && loadElapsed >= 10000; - - // Debug logging for stream availability - React.useEffect(() => { - console.log('๐Ÿ” [StreamsScreen] Streams debug:', { - streamsEmpty, - streamsKeys: Object.keys(streams), - streamsData: Object.entries(streams).map(([key, data]) => ({ - provider: key, - addonName: data.addonName, - streamCount: data.streams?.length || 0, - streams: data.streams?.slice(0, 3).map(s => ({ name: s.name, title: s.title })) || [] - })), - isLoading, - loadingStreams, - loadingEpisodeStreams, - selectedEpisode, - type - }); - }, [streams, streamsEmpty, isLoading, loadingStreams, loadingEpisodeStreams, selectedEpisode, type]); - - - - 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'} - - - - )} - - {isTablet ? ( - - ) : ( - // PHONE LAYOUT (existing structure) - <> - {/* Full Screen Background for Mobile */} - {settings.enableStreamsBackdrop ? ( - - {mobileBackdropSource ? ( - - ) : ( - - )} - {Platform.OS === 'android' && AndroidBlurView ? ( - - ) : ( - - )} - {/* Dark overlay to reduce brightness */} - {Platform.OS === 'ios' && ( - - )} - - ) : ( - - )} - - {type === 'movie' && metadata && ( - - - {metadata.logo && !movieLogoError ? ( - setMovieLogoError(true)} - /> - ) : ( - - {metadata.name} - - )} - - - )} - - {currentEpisode && ( - - - - - - - {currentEpisode ? ( - - - {currentEpisode.episodeString} - - - {currentEpisode.name} - - {!!currentEpisode.overview && ( - - - {currentEpisode.overview} - - - )} - - - {tmdbService.formatAirDate(currentEpisode.air_date)} - - {effectiveEpisodeVote > 0 && ( - - {hasIMDbRating ? ( - <> - - - {effectiveEpisodeVote.toFixed(1)} - - - ) : ( - <> - - - {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 - - )} - - - - - - )} - - {/* Gradient overlay to blend hero section with streams container */} - {metadata?.videos && metadata.videos.length > 1 && selectedEpisode && ( - - - - )} - - - - {!streamsEmpty && ( - - )} - - - {/* 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.filter(Boolean).map((section, sectionIndex) => ( - - {/* Section Header */} - {renderSectionHeader({ section: section! })} - - {/* Stream Cards using FlatList */} - {section!.data && section!.data.length > 0 ? ( - { - if (item && item.url) { - return `${item.url}-${sectionIndex}-${index}`; - } - 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))} - parentId={id} - parentImdbId={imdbId || undefined} - /> - - )} - 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, - })} - /> - ) : null} - - ))} - - {/* Footer Loading */} - {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && ( - - - Loading more sources... - - )} - - - )} - - - )} - setAlertVisible(false)} - /> - - - ); -}; - -// Create a function to generate styles with the current theme colors -const createStyles = (colors: any) => StyleSheet.create({ - container: { - flex: 1, - backgroundColor: 'transparent', - // 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: 'transparent', - 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: 'transparent', - pointerEvents: 'box-none', - zIndex: 1, - }, - streamsHeroBackground: { - width: '100%', - height: '100%', - backgroundColor: 'transparent', - }, - 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, - }, - imdbLogo: { - width: 28, - height: 15, - }, - 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: 'transparent', - 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', - }, - // Tablet-specific styles - tabletLayout: { - flex: 1, - flexDirection: 'row', - position: 'relative', - }, - tabletFullScreenBackground: { - ...StyleSheet.absoluteFillObject, - }, - tabletFullScreenGradient: { - ...StyleSheet.absoluteFillObject, - }, - tabletLeftPanel: { - width: '40%', - justifyContent: 'center', - alignItems: 'center', - padding: 24, - zIndex: 2, - }, - tabletMovieLogoContainer: { - width: '80%', - alignItems: 'center', - justifyContent: 'center', - }, - tabletMovieLogo: { - width: '100%', - height: 120, - marginBottom: 16, - }, - tabletMovieTitle: { - color: colors.highEmphasis, - fontSize: 32, - fontWeight: '900', - textAlign: 'center', - letterSpacing: -0.5, - textShadowColor: 'rgba(0,0,0,0.8)', - textShadowOffset: { width: 0, height: 2 }, - textShadowRadius: 4, - }, - tabletEpisodeInfo: { - width: '80%', - }, - tabletEpisodeText: { - textShadowColor: 'rgba(0,0,0,1)', - textShadowOffset: { width: 0, height: 0 }, - textShadowRadius: 4, - }, - tabletEpisodeNumber: { - fontSize: 18, - fontWeight: 'bold', - marginBottom: 8, - }, - tabletEpisodeTitle: { - fontSize: 28, - fontWeight: 'bold', - marginBottom: 12, - lineHeight: 34, - }, - tabletEpisodeOverview: { - fontSize: 16, - lineHeight: 24, - opacity: 0.95, - }, - tabletRightPanel: { - width: '60%', - flex: 1, - paddingTop: Platform.OS === 'android' ? 60 : 20, - zIndex: 2, - }, - tabletStreamsContent: { - backgroundColor: 'rgba(0,0,0,0.2)', - borderRadius: 24, - margin: 12, - overflow: 'hidden', // Ensures content respects rounded corners - }, - tabletBlurContent: { - flex: 1, - padding: 16, - backgroundColor: 'transparent', - }, - backButtonContainerTablet: { - zIndex: 3, - }, - mobileFullScreenBackground: { - ...StyleSheet.absoluteFillObject, - width: '100%', - height: '100%', - }, - mobileNoBackdropBackground: { - ...StyleSheet.absoluteFillObject, - backgroundColor: colors.darkBackground, - }, - heroBlendOverlay: { - position: 'absolute', - top: 140, // Start at ~64% of hero section, giving 80px of blend within hero - left: 0, - right: 0, - height: Platform.OS === 'android' ? 95 : 180, // Reduce gradient area on Android - zIndex: 0, - pointerEvents: 'none', - }, -}); - -export default memo(StreamsScreen); - +/** + * StreamsScreen - Re-exports from refactored module + * + * The screen has been refactored into separate manageable files: + * - ./streams/StreamsScreen.tsx - Main component + * - ./streams/useStreamsScreen.ts - Custom hook with all logic + * - ./streams/types.ts - TypeScript types + * - ./streams/constants.ts - Constants and config + * - ./streams/utils.ts - Utility functions + * - ./streams/styles.ts - StyleSheet definitions + * - ./streams/components/ - Sub-components (EpisodeHero, MovieHero, StreamsList, MobileStreamsLayout) + */ + +export { StreamsScreen, default } from './streams'; diff --git a/src/screens/streams/StreamsScreen.tsx b/src/screens/streams/StreamsScreen.tsx new file mode 100644 index 0000000..07969a5 --- /dev/null +++ b/src/screens/streams/StreamsScreen.tsx @@ -0,0 +1,215 @@ +import React, { memo } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, StatusBar, Platform } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MaterialIcons } from '@expo/vector-icons'; +import { PaperProvider } from 'react-native-paper'; + +import TabletStreamsLayout from '../../components/TabletStreamsLayout'; +import CustomAlert from '../../components/CustomAlert'; +import { MobileStreamsLayout } from './components'; +import { useStreamsScreen } from './useStreamsScreen'; +import { createStyles } from './styles'; +import { StreamSection } from './types'; + +export const StreamsScreen = () => { + const insets = useSafeAreaInsets(); + const { + // Route params + id, + type, + + // Theme + currentTheme, + colors, + settings, + + // Navigation + navigation, + handleBack, + + // Tablet + isTablet, + + // Alert + alertVisible, + alertTitle, + alertMessage, + alertActions, + openAlert, + closeAlert, + + // Metadata + metadata, + imdbId, + bannerImage, + currentEpisode, + + // Streams + streams, + groupedStreams, + episodeStreams, + sections, + filterItems, + selectedProvider, + handleProviderChange, + handleStreamPress, + + // Loading states + loadingStreams, + loadingEpisodeStreams, + loadingProviders, + streamsEmpty, + showInitialLoading, + showStillFetching, + showNoSourcesError, + hasStremioStreamProviders, + + // Autoplay + isAutoplayWaiting, + autoplayTriggered, + + // Scrapers + activeFetchingScrapers, + scraperLogos, + + // Movie + movieLogoError, + setMovieLogoError, + + // Episode + episodeImage, + effectiveEpisodeVote, + effectiveEpisodeRuntime, + hasIMDbRating, + selectedEpisode, + + // Backdrop + mobileBackdropSource, + gradientColors, + } = useStreamsScreen(); + + const styles = React.useMemo(() => createStyles(colors), [colors]); + + return ( + + + + + {/* Back Button (Android only) */} + {Platform.OS !== 'ios' && ( + + + + + {metadata?.videos && metadata.videos.length > 1 && selectedEpisode + ? 'Back to Episodes' + : 'Back to Info'} + + + + )} + + {/* Tablet Layout */} + {isTablet ? ( + ( + + + {section.title} + + + )} + handleStreamPress={handleStreamPress} + openAlert={openAlert} + settings={settings} + currentTheme={currentTheme} + colors={colors} + navigation={navigation} + insets={insets} + streams={streams} + scraperLogos={scraperLogos} + id={id} + imdbId={imdbId || undefined} + loadingStreams={loadingStreams} + loadingEpisodeStreams={loadingEpisodeStreams} + hasStremioStreamProviders={hasStremioStreamProviders} + /> + ) : ( + /* Mobile Layout */ + + )} + + {/* Custom Alert Dialog */} + + + + ); +}; + +export default memo(StreamsScreen); diff --git a/src/screens/streams/components/EpisodeHero.tsx b/src/screens/streams/components/EpisodeHero.tsx new file mode 100644 index 0000000..79c3791 --- /dev/null +++ b/src/screens/streams/components/EpisodeHero.tsx @@ -0,0 +1,226 @@ +import React, { memo } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import FastImage from '@d11/react-native-fast-image'; +import { MaterialIcons } from '@expo/vector-icons'; + +import AnimatedImage from '../../../components/AnimatedImage'; +import AnimatedText from '../../../components/AnimatedText'; +import AnimatedView from '../../../components/AnimatedView'; +import { tmdbService } from '../../../services/tmdbService'; +import { TMDB_LOGO, IMDb_LOGO } from '../constants'; + +interface EpisodeHeroProps { + episodeImage: string | null; + currentEpisode: { + episodeString: string; + name: string; + overview?: string; + air_date?: string; + season_number: number; + episode_number: number; + }; + effectiveEpisodeVote: number; + effectiveEpisodeRuntime?: number; + hasIMDbRating: boolean; + gradientColors: [string, string, string, string, string]; + colors: any; + enableStreamsBackdrop: boolean; +} + +const EpisodeHero = memo( + ({ + episodeImage, + currentEpisode, + effectiveEpisodeVote, + effectiveEpisodeRuntime, + hasIMDbRating, + gradientColors, + colors, + enableStreamsBackdrop, + }: EpisodeHeroProps) => { + const styles = React.useMemo(() => createStyles(colors), [colors]); + + return ( + + + + + + + + + {currentEpisode.episodeString} + + + {currentEpisode.name} + + {!!currentEpisode.overview && ( + + + {currentEpisode.overview} + + + )} + + + {tmdbService.formatAirDate(currentEpisode.air_date || null)} + + {effectiveEpisodeVote > 0 && ( + + {hasIMDbRating ? ( + <> + + + {effectiveEpisodeVote.toFixed(1)} + + + ) : ( + <> + + {effectiveEpisodeVote.toFixed(1)} + + )} + + )} + {!!effectiveEpisodeRuntime && ( + + + + {effectiveEpisodeRuntime >= 60 + ? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m` + : `${effectiveEpisodeRuntime}m`} + + + )} + + + + + + + + ); + } +); + +const createStyles = (colors: any) => + StyleSheet.create({ + container: { + width: '100%', + height: 220, + marginBottom: 0, + position: 'relative', + backgroundColor: 'transparent', + pointerEvents: 'box-none', + zIndex: 1, + }, + background: { + width: '100%', + height: '100%', + backgroundColor: 'transparent', + }, + gradient: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'flex-end', + padding: 16, + paddingBottom: 0, + }, + content: { + width: '100%', + }, + info: { + width: '100%', + }, + episodeNumber: { + color: colors.primary, + fontSize: 14, + fontWeight: 'bold', + marginBottom: 2, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + title: { + color: colors.highEmphasis, + fontSize: 24, + fontWeight: 'bold', + marginBottom: 4, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, + }, + overview: { + color: colors.mediumEmphasis, + fontSize: 14, + lineHeight: 20, + marginBottom: 2, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + meta: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginTop: 0, + }, + released: { + color: colors.mediumEmphasis, + fontSize: 14, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + rating: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 0, + }, + tmdbLogo: { + width: 20, + height: 14, + }, + imdbLogo: { + width: 28, + height: 15, + }, + ratingText: { + color: colors.highEmphasis, + fontSize: 13, + fontWeight: '700', + marginLeft: 4, + }, + runtime: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + runtimeText: { + color: colors.mediumEmphasis, + fontSize: 13, + fontWeight: '600', + }, + }); + +EpisodeHero.displayName = 'EpisodeHero'; + +export default EpisodeHero; diff --git a/src/screens/streams/components/MobileStreamsLayout.tsx b/src/screens/streams/components/MobileStreamsLayout.tsx new file mode 100644 index 0000000..34bbc4d --- /dev/null +++ b/src/screens/streams/components/MobileStreamsLayout.tsx @@ -0,0 +1,400 @@ +import React, { memo } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, Platform } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { BlurView as ExpoBlurView } from 'expo-blur'; +import { MaterialIcons } from '@expo/vector-icons'; +import { NavigationProp } from '@react-navigation/native'; + +import AnimatedImage from '../../../components/AnimatedImage'; +import ProviderFilter from '../../../components/ProviderFilter'; +import PulsingChip from '../../../components/PulsingChip'; +import EpisodeHero from './EpisodeHero'; +import MovieHero from './MovieHero'; +import StreamsList from './StreamsList'; +import { Stream } from '../../../types/metadata'; +import { StreamSection, FilterItem, GroupedStreams, LoadingProviders, ScraperLogos } from '../types'; + +// Lazy-safe community blur import for Android +let AndroidBlurView: any = null; +if (Platform.OS === 'android') { + try { + AndroidBlurView = require('@react-native-community/blur').BlurView; + } catch (_) { + AndroidBlurView = null; + } +} + +interface MobileStreamsLayoutProps { + // Navigation + navigation: NavigationProp; + + // Theme + currentTheme: any; + colors: any; + settings: any; + + // Type + type: string; + + // Metadata + metadata: any; + currentEpisode: any; + selectedEpisode: string | undefined; + + // Movie hero + movieLogoError: boolean; + setMovieLogoError: (error: boolean) => void; + + // Episode hero + episodeImage: string | null; + effectiveEpisodeVote: number; + effectiveEpisodeRuntime?: number; + hasIMDbRating: boolean; + gradientColors: [string, string, string, string, string]; + + // Backdrop + mobileBackdropSource: string | null | undefined; + + // Streams + sections: StreamSection[]; + streams: GroupedStreams; + filterItems: FilterItem[]; + selectedProvider: string; + handleProviderChange: (provider: string) => void; + handleStreamPress: (stream: Stream) => void; + + // Loading + loadingProviders: LoadingProviders; + loadingStreams: boolean; + loadingEpisodeStreams: boolean; + hasStremioStreamProviders: boolean; + streamsEmpty: boolean; + showInitialLoading: boolean; + showStillFetching: boolean; + showNoSourcesError: boolean; + + // Autoplay + isAutoplayWaiting: boolean; + autoplayTriggered: boolean; + + // Scrapers + activeFetchingScrapers: string[]; + scraperLogos: ScraperLogos; + + // Alert + openAlert: (title: string, message: string) => void; + + // IDs + id: string; + imdbId?: string; +} + +const MobileStreamsLayout = memo( + ({ + navigation, + currentTheme, + colors, + settings, + type, + metadata, + currentEpisode, + selectedEpisode, + movieLogoError, + setMovieLogoError, + episodeImage, + effectiveEpisodeVote, + effectiveEpisodeRuntime, + hasIMDbRating, + gradientColors, + mobileBackdropSource, + sections, + streams, + filterItems, + selectedProvider, + handleProviderChange, + handleStreamPress, + loadingProviders, + loadingStreams, + loadingEpisodeStreams, + hasStremioStreamProviders, + streamsEmpty, + showInitialLoading, + showStillFetching, + showNoSourcesError, + isAutoplayWaiting, + autoplayTriggered, + activeFetchingScrapers, + scraperLogos, + openAlert, + id, + imdbId, + }: MobileStreamsLayoutProps) => { + const styles = React.useMemo(() => createStyles(colors), [colors]); + const isEpisode = metadata?.videos && metadata.videos.length > 1 && selectedEpisode; + + return ( + <> + {/* Full Screen Background */} + {settings.enableStreamsBackdrop ? ( + + {mobileBackdropSource ? ( + + ) : ( + + )} + {Platform.OS === 'android' && AndroidBlurView ? ( + + ) : ( + + )} + {Platform.OS === 'ios' && ( + + )} + + ) : ( + + )} + + {/* Movie Hero */} + {type === 'movie' && metadata && ( + + )} + + {/* Episode Hero */} + {currentEpisode && ( + + )} + + {/* Hero blend overlay for episodes */} + {isEpisode && ( + + + + )} + + {/* Main Content */} + + {/* Provider Filter */} + + {!streamsEmpty && ( + + )} + + + {/* Active Scrapers Status */} + {activeFetchingScrapers.length > 0 && ( + + Fetching from: + + {activeFetchingScrapers.map((scraperName, index) => ( + + ))} + + + )} + + {/* Content */} + {showNoSourcesError ? ( + + + No streaming sources available + Please add streaming sources in settings + navigation.navigate('Addons' as never)} + > + Add Sources + + + ) : streamsEmpty ? ( + showInitialLoading ? ( + + + + {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} + + + ) : showStillFetching ? ( + + + Still fetching streamsโ€ฆ + + ) : ( + + + No streams available + + ) + ) : ( + + )} + + + ); + } +); + +const createStyles = (colors: any) => + StyleSheet.create({ + mobileFullScreenBackground: { + ...StyleSheet.absoluteFillObject, + width: '100%', + height: '100%', + }, + mobileNoBackdropBackground: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.darkBackground, + }, + heroBlendOverlay: { + position: 'absolute', + top: 140, + left: 0, + right: 0, + height: Platform.OS === 'android' ? 95 : 180, + zIndex: 0, + pointerEvents: 'none', + }, + streamsMainContent: { + flex: 1, + backgroundColor: 'transparent', + paddingTop: 12, + zIndex: 1, + ...(Platform.OS === 'ios' && { + opacity: 1, + shouldRasterizeIOS: false, + }), + }, + streamsMainContentMovie: { + paddingTop: Platform.OS === 'android' ? 10 : 15, + }, + filterContainer: { + paddingHorizontal: 12, + paddingBottom: 8, + }, + 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, + }, + noStreams: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + noStreamsText: { + color: colors.textMuted, + fontSize: 16, + marginTop: 16, + }, + 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', + }, + loadingContainer: { + alignItems: 'center', + paddingVertical: 24, + }, + loadingText: { + color: colors.primary, + fontSize: 12, + marginLeft: 4, + fontWeight: '500', + }, + }); + +MobileStreamsLayout.displayName = 'MobileStreamsLayout'; + +export default MobileStreamsLayout; diff --git a/src/screens/streams/components/MovieHero.tsx b/src/screens/streams/components/MovieHero.tsx new file mode 100644 index 0000000..d0e6027 --- /dev/null +++ b/src/screens/streams/components/MovieHero.tsx @@ -0,0 +1,80 @@ +import React, { memo } from 'react'; +import { View, StyleSheet, Platform, Dimensions } from 'react-native'; +import FastImage from '@d11/react-native-fast-image'; + +import AnimatedText from '../../../components/AnimatedText'; + +const { width } = Dimensions.get('window'); + +interface MovieHeroProps { + metadata: { + name: string; + logo?: string; + }; + movieLogoError: boolean; + setMovieLogoError: (error: boolean) => void; + colors: any; + enableStreamsBackdrop: boolean; +} + +const MovieHero = memo( + ({ metadata, movieLogoError, setMovieLogoError, colors, enableStreamsBackdrop }: MovieHeroProps) => { + const styles = React.useMemo(() => createStyles(colors), [colors]); + + return ( + + + {metadata.logo && !movieLogoError ? ( + setMovieLogoError(true)} + /> + ) : ( + + {metadata.name} + + )} + + + ); + } +); + +const createStyles = (colors: any) => + StyleSheet.create({ + container: { + width: '100%', + height: 140, + backgroundColor: 'transparent', + pointerEvents: 'box-none', + justifyContent: 'center', + paddingTop: Platform.OS === 'android' ? 65 : 35, + }, + content: { + width: '100%', + height: 80, + alignItems: 'center', + justifyContent: 'center', + }, + logo: { + width: '100%', + height: 80, + maxWidth: width * 0.85, + }, + title: { + color: colors.highEmphasis, + fontSize: 28, + fontWeight: '900', + textAlign: 'center', + letterSpacing: -0.5, + paddingHorizontal: 20, + }, + }); + +MovieHero.displayName = 'MovieHero'; + +export default MovieHero; diff --git a/src/screens/streams/components/StreamsList.tsx b/src/screens/streams/components/StreamsList.tsx new file mode 100644 index 0000000..1844067 --- /dev/null +++ b/src/screens/streams/components/StreamsList.tsx @@ -0,0 +1,272 @@ +import React, { memo, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + FlatList, + ActivityIndicator, + Platform, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import StreamCard from '../../../components/StreamCard'; +import { Stream } from '../../../types/metadata'; +import { StreamSection, GroupedStreams, LoadingProviders, ScraperLogos } from '../types'; + +interface StreamsListProps { + sections: StreamSection[]; + streams: GroupedStreams; + loadingProviders: LoadingProviders; + loadingStreams: boolean; + loadingEpisodeStreams: boolean; + hasStremioStreamProviders: boolean; + isAutoplayWaiting: boolean; + autoplayTriggered: boolean; + handleStreamPress: (stream: Stream) => void; + openAlert: (title: string, message: string) => void; + settings: any; + currentTheme: any; + colors: any; + scraperLogos: ScraperLogos; + metadata?: any; + type: string; + currentEpisode?: any; + episodeImage?: string | null; + id: string; + imdbId?: string; +} + +const StreamsList = memo( + ({ + sections, + streams, + loadingProviders, + loadingStreams, + loadingEpisodeStreams, + hasStremioStreamProviders, + isAutoplayWaiting, + autoplayTriggered, + handleStreamPress, + openAlert, + settings, + currentTheme, + colors, + scraperLogos, + metadata, + type, + currentEpisode, + episodeImage, + id, + imdbId, + }: StreamsListProps) => { + const insets = useSafeAreaInsets(); + const styles = React.useMemo(() => createStyles(colors), [colors]); + + const renderSectionHeader = useCallback( + ({ section }: { section: StreamSection }) => { + const isProviderLoading = loadingProviders[section.addonId]; + + return ( + + + {section.title} + {isProviderLoading && ( + + + + Loading... + + + )} + + + ); + }, + [loadingProviders, styles, colors.primary] + ); + + return ( + + {/* Autoplay overlay */} + {isAutoplayWaiting && !autoplayTriggered && ( + + + + Starting best stream... + + + )} + + + {sections.filter(Boolean).map((section, sectionIndex) => ( + + {renderSectionHeader({ section: section! })} + + {section!.data && section!.data.length > 0 ? ( + { + if (item && item.url) { + return `${item.url}-${sectionIndex}-${index}`; + } + 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 ? scraperLogos[(item.addonId || (item as any).addon) as string] || null : null) + } + showAlert={(t: string, m: string) => 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) + ) + } + parentId={id} + parentImdbId={imdbId} + /> + + )} + scrollEnabled={false} + initialNumToRender={6} + maxToRenderPerBatch={2} + windowSize={3} + removeClippedSubviews={true} + showsVerticalScrollIndicator={false} + getItemLayout={(data, index) => ({ + length: 78, + offset: 78 * index, + index, + })} + /> + ) : null} + + ))} + + {/* Footer Loading */} + {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && ( + + + Loading more sources... + + )} + + + ); + } +); + +const createStyles = (colors: any) => + StyleSheet.create({ + streamsContent: { + flex: 1, + width: '100%', + zIndex: 2, + }, + streamsContainer: { + paddingHorizontal: 12, + paddingBottom: 20, + width: '100%', + }, + streamGroupTitle: { + color: colors.highEmphasis, + fontSize: 14, + fontWeight: '700', + marginBottom: 6, + marginTop: 0, + opacity: 0.9, + backgroundColor: 'transparent', + }, + 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', + }, + footerLoading: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + }, + footerLoadingText: { + color: colors.primary, + fontSize: 12, + marginLeft: 8, + fontWeight: '500', + }, + }); + +StreamsList.displayName = 'StreamsList'; + +export default StreamsList; diff --git a/src/screens/streams/components/index.ts b/src/screens/streams/components/index.ts new file mode 100644 index 0000000..259d0fa --- /dev/null +++ b/src/screens/streams/components/index.ts @@ -0,0 +1,4 @@ +export { default as EpisodeHero } from './EpisodeHero'; +export { default as MovieHero } from './MovieHero'; +export { default as StreamsList } from './StreamsList'; +export { default as MobileStreamsLayout } from './MobileStreamsLayout'; diff --git a/src/screens/streams/constants.ts b/src/screens/streams/constants.ts new file mode 100644 index 0000000..b60d899 --- /dev/null +++ b/src/screens/streams/constants.ts @@ -0,0 +1,24 @@ +import { Dimensions } from 'react-native'; + +const { width, height } = Dimensions.get('window'); + +export const SCREEN_WIDTH = width; +export const SCREEN_HEIGHT = height; + +// Image URLs +export const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; +export const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png'; +export const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png'; +export 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'; + +// Timeouts +export const MKV_HEAD_TIMEOUT_MS = 600; + +// Tablet breakpoint +export const TABLET_BREAKPOINT = 768; + +// Hero section height +export const HERO_HEIGHT = 220; + +// Movie title container height +export const MOVIE_TITLE_HEIGHT = 140; diff --git a/src/screens/streams/index.ts b/src/screens/streams/index.ts new file mode 100644 index 0000000..17baba1 --- /dev/null +++ b/src/screens/streams/index.ts @@ -0,0 +1,7 @@ +export { StreamsScreen, default } from './StreamsScreen'; +export { useStreamsScreen } from './useStreamsScreen'; +export * from './types'; +export * from './constants'; +export * from './utils'; +export { createStyles } from './styles'; +export * from './components'; diff --git a/src/screens/streams/styles.ts b/src/screens/streams/styles.ts new file mode 100644 index 0000000..43e0766 --- /dev/null +++ b/src/screens/streams/styles.ts @@ -0,0 +1,367 @@ +import { StyleSheet, Platform } from 'react-native'; +import { SCREEN_WIDTH, HERO_HEIGHT, MOVIE_TITLE_HEIGHT } from './constants'; + +export const createStyles = (colors: any) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'transparent', + ...(Platform.OS === 'ios' && { + opacity: 1, + shouldRasterizeIOS: false, + 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: 'transparent', + paddingTop: 12, + zIndex: 1, + ...(Platform.OS === 'ios' && { + opacity: 1, + 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', + }, + noStreams: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + noStreamsText: { + color: colors.textMuted, + fontSize: 16, + marginTop: 16, + }, + 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', + }, + streamsHeroContainer: { + width: '100%', + height: HERO_HEIGHT, + marginBottom: 0, + position: 'relative', + backgroundColor: 'transparent', + pointerEvents: 'box-none', + zIndex: 1, + }, + streamsHeroBackground: { + width: '100%', + height: '100%', + backgroundColor: 'transparent', + }, + 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', + marginTop: 0, + }, + tmdbLogo: { + width: 20, + height: 14, + }, + imdbLogo: { + width: 28, + height: 15, + }, + streamsHeroRatingText: { + color: colors.highEmphasis, + fontSize: 13, + fontWeight: '700', + marginLeft: 4, + }, + streamsHeroRuntime: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + streamsHeroRuntimeText: { + color: colors.mediumEmphasis, + fontSize: 13, + fontWeight: '600', + }, + loadingContainer: { + alignItems: 'center', + paddingVertical: 24, + }, + loadingText: { + color: colors.primary, + fontSize: 12, + marginLeft: 4, + fontWeight: '500', + }, + footerLoading: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + }, + footerLoadingText: { + color: colors.primary, + fontSize: 12, + marginLeft: 8, + fontWeight: '500', + }, + movieTitleContainer: { + width: '100%', + height: MOVIE_TITLE_HEIGHT, + backgroundColor: 'transparent', + pointerEvents: 'box-none', + justifyContent: 'center', + paddingTop: Platform.OS === 'android' ? 65 : 35, + }, + movieTitleContent: { + width: '100%', + height: 80, + alignItems: 'center', + justifyContent: 'center', + }, + movieLogo: { + width: '100%', + height: 80, + maxWidth: SCREEN_WIDTH * 0.85, + }, + movieTitle: { + color: colors.highEmphasis, + fontSize: 28, + fontWeight: '900', + textAlign: 'center', + letterSpacing: -0.5, + paddingHorizontal: 20, + }, + 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', + }, + 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, + }, + backButtonContainerTablet: { + zIndex: 3, + }, + mobileFullScreenBackground: { + ...StyleSheet.absoluteFillObject, + width: '100%', + height: '100%', + }, + mobileNoBackdropBackground: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.darkBackground, + }, + heroBlendOverlay: { + position: 'absolute', + top: 140, + left: 0, + right: 0, + height: Platform.OS === 'android' ? 95 : 180, + zIndex: 0, + pointerEvents: 'none', + }, + 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, + }, + }); diff --git a/src/screens/streams/types.ts b/src/screens/streams/types.ts new file mode 100644 index 0000000..d0bc566 --- /dev/null +++ b/src/screens/streams/types.ts @@ -0,0 +1,71 @@ +import { Stream } from '../../types/metadata'; + +export interface StreamProviderData { + addonName: string; + streams: Stream[]; +} + +export interface GroupedStreams { + [addonId: string]: StreamProviderData; +} + +export interface StreamSection { + title: string; + addonId: string; + data: Stream[]; + isEmptyDueToQualityFilter?: boolean; +} + +export interface FilterItem { + id: string; + name: string; +} + +export interface ProviderStatus { + loading: boolean; + success: boolean; + error: boolean; + message: string; + timeStarted: number; + timeCompleted: number; +} + +export interface LoadingProviders { + [key: string]: boolean; +} + +export interface ProviderStatusMap { + [key: string]: ProviderStatus; +} + +export interface ProviderLoadTimes { + [key: string]: number; +} + +export interface ScraperLogos { + [key: string]: string; +} + +export interface IMDbRatingsMap { + [key: string]: number; +} + +export interface TMDBEpisodeOverride { + vote_average?: number; + runtime?: number; + still_path?: string; +} + +export interface AlertAction { + label: string; + onPress: () => void; + style?: object; +} + +export interface StreamsScreenParams { + id: string; + type: 'movie' | 'series' | 'tv' | 'other'; + episodeId?: string; + episodeThumbnail?: string; + fromPlayer?: boolean; +} diff --git a/src/screens/streams/useStreamsScreen.ts b/src/screens/streams/useStreamsScreen.ts new file mode 100644 index 0000000..7af55b8 --- /dev/null +++ b/src/screens/streams/useStreamsScreen.ts @@ -0,0 +1,1092 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { Dimensions, Platform, Linking } from 'react-native'; +import { useRoute, useNavigation } from '@react-navigation/native'; +import { RouteProp } from '@react-navigation/native'; + +import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator'; +import { useMetadata } from '../../hooks/useMetadata'; +import { useMetadataAssets } from '../../hooks/useMetadataAssets'; +import { useSettings } from '../../hooks/useSettings'; +import { useTheme } from '../../contexts/ThemeContext'; +import { useTrailer } from '../../contexts/TrailerContext'; +import { useToast } from '../../contexts/ToastContext'; +import { useDominantColor } from '../../hooks/useDominantColor'; +import { Stream } from '../../types/metadata'; +import { stremioService } from '../../services/stremioService'; +import { localScraperService } from '../../services/pluginService'; +import { VideoPlayerService } from '../../services/videoPlayerService'; +import { streamCacheService } from '../../services/streamCacheService'; +import { tmdbService } from '../../services/tmdbService'; +import { logger } from '../../utils/logger'; +import { TABLET_BREAKPOINT } from './constants'; +import { + filterStreamsByQuality, + filterStreamsByLanguage, + getQualityNumeric, + detectMkvViaHead, + inferVideoTypeFromUrl, + filterHeadersForVidrock, + sortStreamsByQuality, +} from './utils'; +import { + GroupedStreams, + StreamSection, + FilterItem, + LoadingProviders, + ScraperLogos, + IMDbRatingsMap, + TMDBEpisodeOverride, + AlertAction, +} from './types'; +import { MKV_HEAD_TIMEOUT_MS } from './constants'; + +// Cache for scraper logos +const scraperLogoCache = new Map(); +let scraperLogoCachePromise: Promise | null = null; + +export const useStreamsScreen = () => { + 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(); + const { showSuccess, showInfo } = useToast(); + + // Dimension tracking + const [dimensions, setDimensions] = useState(Dimensions.get('window')); + const prevDimensionsRef = useRef({ width: dimensions.width, height: dimensions.height }); + + const deviceWidth = dimensions.width; + const isTablet = useMemo(() => deviceWidth >= TABLET_BREAKPOINT, [deviceWidth]); + + // Refs + const isMounted = useRef(true); + const hasDoneInitialLoadRef = useRef(false); + const isLoadingStreamsRef = useRef(false); + const lastLoadedIdRef = useRef(null); + + // Alert state + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState([]); + + // Loading and provider state + const [streamsLoadStart, setStreamsLoadStart] = useState(null); + const [loadingProviders, setLoadingProviders] = useState({}); + const [selectedProvider, setSelectedProvider] = useState('all'); + const [availableProviders, setAvailableProviders] = useState>(new Set()); + const prevProvidersRef = useRef>(new Set()); + + // Autoplay state + const [autoplayTriggered, setAutoplayTriggered] = useState(false); + const [isAutoplayWaiting, setIsAutoplayWaiting] = useState(false); + + // Sources state + const [hasStreamProviders, setHasStreamProviders] = useState(true); + const [hasStremioStreamProviders, setHasStremioStreamProviders] = useState(true); + const [showNoSourcesError, setShowNoSourcesError] = useState(false); + + // Logo error state + const [movieLogoError, setMovieLogoError] = useState(false); + + // Scraper logos + const [scraperLogos, setScraperLogos] = useState({}); + + // TMDB episode data + const [tmdbEpisodeOverride, setTmdbEpisodeOverride] = useState(null); + const [imdbRatingsMap, setImdbRatingsMap] = useState({}); + + // Get metadata from hook + const { + metadata, + episodes, + groupedStreams, + loadingStreams, + episodeStreams, + loadingEpisodeStreams, + selectedEpisode, + loadStreams, + loadEpisodeStreams, + setSelectedEpisode, + groupedEpisodes, + imdbId, + scraperStatuses, + activeFetchingScrapers, + addonResponseOrder, + } = useMetadata({ id, type }); + + // Get banner image + const setMetadataStub = useCallback(() => {}, []); + const memoizedSettings = useMemo( + () => settings, + [settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB] + ); + const { bannerImage } = useMetadataAssets(metadata, id, type, imdbId, memoizedSettings, setMetadataStub); + + // Dimension listener + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + const widthChanged = Math.abs(window.width - prevDimensionsRef.current.width) > 1; + const heightChanged = Math.abs(window.height - prevDimensionsRef.current.height) > 1; + + if (widthChanged || heightChanged) { + prevDimensionsRef.current = { width: window.width, height: window.height }; + setDimensions(window); + } + }); + return () => subscription?.remove(); + }, []); + + // Pause trailer on mount + useEffect(() => { + pauseTrailer(); + return () => resumeTrailer(); + }, [pauseTrailer, resumeTrailer]); + + // Reset movie logo error + useEffect(() => { + setMovieLogoError(false); + }, [id]); + + // Preload scraper logos + useEffect(() => { + const preloadScraperLogos = async () => { + if (!scraperLogoCachePromise) { + scraperLogoCachePromise = (async () => { + try { + const availableScrapers = await localScraperService.getAvailableScrapers(); + const map: ScraperLogos = {}; + 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 + } + })(); + } + }; + preloadScraperLogos(); + }, []); + + // Open alert helper + const openAlert = useCallback( + (title: string, message: string, actions?: AlertAction[]) => { + 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); + } + }, + [] + ); + + const closeAlert = useCallback(() => setAlertVisible(false), []); + + // Navigation handlers + const handleBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + (navigation as any).navigate('MainTabs'); + } + }, [navigation]); + + const handleProviderChange = useCallback((provider: string) => { + setSelectedProvider(provider); + }, []); + + // Quality and language filtering callbacks + const filterByQuality = useCallback( + (streams: Stream[]) => filterStreamsByQuality(streams, settings.excludedQualities || []), + [settings.excludedQualities] + ); + + const filterByLanguage = useCallback( + (streams: Stream[]) => filterStreamsByLanguage(streams, settings.excludedLanguages || []), + [settings.excludedLanguages] + ); + + // Get best stream for autoplay + const getBestStream = useCallback( + (streamsData: GroupedStreams): Stream | null => { + if (!streamsData || Object.keys(streamsData).length === 0) { + return null; + } + + const getProviderPriority = (addonId: string): number => { + const installedAddons = stremioService.getInstalledAddons(); + const addonIndex = installedAddons.findIndex(addon => addon.id === addonId); + if (addonIndex !== -1) { + return 50 - addonIndex; + } + return 0; + }; + + const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number }> = []; + + Object.entries(streamsData).forEach(([addonId, { streams }]) => { + const qualityFiltered = filterByQuality(streams); + const filteredStreams = filterByLanguage(qualityFiltered); + + 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; + + allStreams.sort((a, b) => { + if (a.quality !== b.quality) return b.quality - a.quality; + 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)` + ); + + return allStreams[0].stream; + }, + [filterByQuality, filterByLanguage] + ); + + // Current episode + const currentEpisode = useMemo(() => { + if (!selectedEpisode) return null; + 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 episode + useEffect(() => { + const hydrateEpisodeFromTmdb = async () => { + try { + setTmdbEpisodeOverride(null); + if (type !== 'series' || !currentEpisode || !id) return; + + const needsHydration = + !(currentEpisode as any).runtime || + !(currentEpisode as any).vote_average || + !currentEpisode.still_path; + if (!needsHydration) return; + + 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]); + + // Fetch IMDb ratings + useEffect(() => { + const fetchIMDbRatings = async () => { + try { + if (type !== 'series' && type !== 'other') return; + if (!id || !currentEpisode) return; + + 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 ratings = await tmdbService.getIMDbRatings(tmdbShowId); + + if (ratings) { + const ratingsMap: IMDbRatingsMap = {}; + ratings.forEach(season => { + if (season.episodes) { + season.episodes.forEach(episode => { + const key = `${episode.season_number}:${episode.episode_number}`; + if (episode.vote_average) { + ratingsMap[key] = episode.vote_average; + } + }); + } + }); + setImdbRatingsMap(ratingsMap); + } + } catch (err) { + logger.error('[StreamsScreen] Failed to fetch IMDb ratings:', err); + } + }; + + fetchIMDbRatings(); + }, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]); + + // Navigate to player + const navigateToPlayer = useCallback( + async (stream: Stream, options?: { headers?: Record }) => { + const finalHeaders = filterHeadersForVidrock(options?.headers || (stream.headers as any)); + + const streamsToPass = type === 'series' || (type === 'other' && selectedEpisode) ? episodeStreams : groupedStreams; + const streamName = stream.name || stream.title || 'Unnamed Stream'; + const streamProvider = stream.addonId || stream.addonName || stream.name; + + // Save stream to cache + try { + const epId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined; + const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; + const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; + const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; + + await streamCacheService.saveStreamToCache( + id, + type, + stream, + metadata, + epId, + season, + episode, + episodeTitle, + imdbId || undefined, + settings.streamCacheTTL + ); + } catch (error) { + logger.warn('[StreamsScreen] Failed to save stream to cache:', error); + } + + let videoType = inferVideoTypeFromUrl(stream.url); + try { + const providerId = stream.addonId || (stream as any).addon || ''; + if (!videoType && /xprime/i.test(providerId)) { + videoType = 'm3u8'; + } + } catch {} + + const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; + + navigation.navigate(playerRoute as any, { + uri: stream.url as any, + 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, + streamName, + headers: finalHeaders, + id, + type, + episodeId: (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined, + imdbId: imdbId || undefined, + availableStreams: streamsToPass, + backdrop: bannerImage, + videoType, + } as any); + }, + [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL] + ); + + // Handle stream press + const handleStreamPress = useCallback( + async (stream: Stream) => { + try { + if (!stream.url) return; + + // Block magnet links + if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) { + openAlert('Not supported', 'Torrent streaming is not supported yet.'); + return; + } + + // iOS MKV detection + 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; + navigateToPlayer(stream, { headers: mergedHeaders }); + return; + } + } catch (e) { + logger.warn('[StreamsScreen] MKV detection failed:', e); + } + } + } + + // iOS external player + if (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal') { + try { + const streamUrl = encodeURIComponent(stream.url); + let externalPlayerUrls: string[] = []; + + 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}`, + ]; + 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: + navigateToPlayer(stream); + return; + } + + const tryNextUrl = (index: number) => { + if (index >= externalPlayerUrls.length) { + Linking.openURL(stream.url!) + .catch(() => navigateToPlayer(stream)); + return; + } + Linking.openURL(externalPlayerUrls[index]) + .catch(() => tryNextUrl(index + 1)); + }; + + tryNextUrl(0); + } catch { + navigateToPlayer(stream); + } + } + // Android external player + else if (Platform.OS === 'android' && settings.useExternalPlayer) { + try { + const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:'); + if (isMagnet) { + Linking.openURL(stream.url).catch(() => navigateToPlayer(stream)); + } else { + 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) navigateToPlayer(stream); + } + } catch { + navigateToPlayer(stream); + } + } else { + navigateToPlayer(stream); + } + } catch { + navigateToPlayer(stream); + } + }, + [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer, openAlert, metadata, type, currentEpisode] + ); + + // Update providers when streams change + useEffect(() => { + if (!isMounted.current) return; + + const currentStreamsData = + metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; + + const providersWithStreams = Object.entries(currentStreamsData) + .filter(([_, data]) => data.streams && data.streams.length > 0) + .map(([providerId]) => providerId); + + if (providersWithStreams.length > 0) { + const hasNewProviders = providersWithStreams.some(provider => !prevProvidersRef.current.has(provider)); + + if (hasNewProviders) { + setAvailableProviders(prevProviders => { + const newProviders = new Set([...prevProviders, ...providersWithStreams]); + prevProvidersRef.current = newProviders; + return newProviders; + }); + } + } + + // Update loading states + const expectedProviders = ['stremio']; + setLoadingProviders(prevLoading => { + const nextLoading = { ...prevLoading }; + let changed = false; + expectedProviders.forEach(providerId => { + const providerExists = currentStreamsData[providerId]; + const shouldStopLoading = providerExists || !(loadingStreams || loadingEpisodeStreams); + const value = !shouldStopLoading; + if (nextLoading[providerId] !== value) { + nextLoading[providerId] = value; + changed = true; + } + }); + return changed ? nextLoading : prevLoading; + }); + }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type, metadata, selectedEpisode]); + + // Reset autoplay on episode change + useEffect(() => { + setAutoplayTriggered(false); + }, [selectedEpisode]); + + // Reset provider if no longer available + useEffect(() => { + const isSpecialFilter = selectedProvider === 'all' || selectedProvider === 'grouped-plugins'; + if (isSpecialFilter) return; + + const currentStreamsData = + metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; + const hasStreamsForProvider = + currentStreamsData[selectedProvider] && + currentStreamsData[selectedProvider].streams && + currentStreamsData[selectedProvider].streams.length > 0; + const isAvailableProvider = availableProviders.has(selectedProvider); + + if (!isAvailableProvider && !hasStreamsForProvider) { + setSelectedProvider('all'); + } + }, [selectedProvider, availableProviders, episodeStreams, groupedStreams, type, metadata, selectedEpisode]); + + // Check providers and load streams + useEffect(() => { + // Build a unique key for the current content + const currentKey = `${id}:${type}:${episodeId || ''}`; + + // Reset refs if content changed + if (lastLoadedIdRef.current !== currentKey) { + hasDoneInitialLoadRef.current = false; + isLoadingStreamsRef.current = false; + lastLoadedIdRef.current = currentKey; + } + + // Only proceed if we haven't done the initial load for this content + if (hasDoneInitialLoadRef.current) return; + + const checkProviders = async () => { + if (isLoadingStreamsRef.current) return; + isLoadingStreamsRef.current = true; + hasDoneInitialLoadRef.current = true; + + try { + const hasStremioProviders = await stremioService.hasStreamProviders(type); + const hasLocalScrapers = settings.enableLocalScrapers && (await localScraperService.hasScrapers()); + 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 (episodeId) { + setLoadingProviders({ stremio: true }); + setSelectedEpisode(episodeId); + setStreamsLoadStart(Date.now()); + loadEpisodeStreams(episodeId); + } else if (type === 'movie' || type === 'tv') { + setStreamsLoadStart(Date.now()); + if (type === 'tv') setLoadingProviders({ stremio: true }); + loadStreams(); + } else { + setLoadingProviders({ stremio: true }); + setStreamsLoadStart(Date.now()); + loadStreams(); + } + + setAutoplayTriggered(false); + if (settings.autoplayBestStream && !fromPlayer) { + setIsAutoplayWaiting(true); + } else { + setIsAutoplayWaiting(false); + } + } + } finally { + isLoadingStreamsRef.current = false; + } + }; + + checkProviders(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, id, episodeId, settings.autoplayBestStream, fromPlayer, settings.enableLocalScrapers]); + + // Autoplay effect + 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...'); + setAutoplayTriggered(true); + setIsAutoplayWaiting(false); + handleStreamPress(bestStream); + } else { + setIsAutoplayWaiting(false); + } + } + } + }, [ + settings.autoplayBestStream, + autoplayTriggered, + isAutoplayWaiting, + type, + episodeStreams, + groupedStreams, + getBestStream, + handleStreamPress, + metadata, + selectedEpisode, + ]); + + // Cleanup on unmount + useEffect(() => { + return () => { + isMounted.current = false; + scraperLogoCache.clear(); + scraperLogoCachePromise = null; + }; + }, []); + + // Filter items for provider selector + const filterItems = useMemo((): FilterItem[] => { + const installedAddons = stremioService.getInstalledAddons(); + const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; + + const providersWithStreams = Object.keys(streams).filter(key => { + const providerData = streams[key]; + return providerData && providerData.streams && providerData.streams.length > 0; + }); + + const allProviders = new Set([ + ...Array.from(availableProviders).filter( + (provider: string) => streams[provider] && streams[provider].streams && streams[provider].streams.length > 0 + ), + ...providersWithStreams, + ]); + + 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: FilterItem[] = [{ id: 'all', name: 'All Providers' }]; + + 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 }); + }); + + if (pluginProviders.length > 0) { + filterChips.push({ id: 'grouped-plugins', name: localScraperService.getRepositoryName() }); + } + + return filterChips; + } + + return [ + { id: 'all', name: 'All Providers' }, + ...Array.from(allProviders) + .sort((a, b) => { + 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]; + 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, metadata, selectedEpisode]); + + // Sections for stream list + const sections = useMemo((): StreamSection[] => { + const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; + const installedAddons = stremioService.getInstalledAddons(); + + const filteredEntries = Object.entries(streams).filter(([addonId]) => { + if (selectedProvider === 'all') return true; + if (settings.streamDisplayMode === 'grouped' && selectedProvider === 'grouped-plugins') { + const isInstalledAddon = installedAddons.some(addon => addon.id === addonId); + return !isInstalledAddon; + } + return addonId === selectedProvider; + }); + + const sortedEntries = filteredEntries.sort(([addonIdA], [addonIdB]) => { + const indexA = addonResponseOrder.indexOf(addonIdA); + const indexB = addonResponseOrder.indexOf(addonIdB); + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return 0; + }); + + if (settings.streamDisplayMode === 'grouped') { + const addonStreams: Stream[] = []; + const pluginStreams: Stream[] = []; + + sortedEntries.forEach(([addonId, { streams: providerStreams }]) => { + const isInstalledAddon = installedAddons.some(addon => addon.id === addonId); + + if (isInstalledAddon) { + addonStreams.push(...providerStreams); + } else { + const qualityFiltered = filterByQuality(providerStreams); + const filteredStreams = filterByLanguage(qualityFiltered); + if (filteredStreams.length > 0) { + pluginStreams.push(...filteredStreams); + } + } + }); + + let combinedStreams = [...addonStreams]; + + if (settings.streamSortMode === 'quality-then-scraper' && pluginStreams.length > 0) { + combinedStreams.push(...sortStreamsByQuality(pluginStreams)); + } else { + combinedStreams.push(...pluginStreams); + } + + if (combinedStreams.length === 0) return []; + + return [ + { + title: 'Available Streams', + addonId: 'grouped-all', + data: combinedStreams, + isEmptyDueToQualityFilter: false, + }, + ]; + } + + return sortedEntries + .map(([addonId, { addonName, streams: providerStreams }]) => { + const isInstalledAddon = installedAddons.some(addon => addon.id === addonId); + let filteredStreams = providerStreams; + + if (!isInstalledAddon) { + const qualityFiltered = filterByQuality(providerStreams); + filteredStreams = filterByLanguage(qualityFiltered); + } + + if (filteredStreams.length === 0) return null; + + let processedStreams = filteredStreams; + if (!isInstalledAddon && settings.streamSortMode === 'quality-then-scraper') { + processedStreams = sortStreamsByQuality(filteredStreams); + } + + return { + title: addonName, + addonId, + data: processedStreams, + isEmptyDueToQualityFilter: false, + }; + }) + .filter(Boolean) as StreamSection[]; + }, [ + selectedProvider, + type, + episodeStreams, + groupedStreams, + settings.streamDisplayMode, + filterByQuality, + filterByLanguage, + addonResponseOrder, + settings.streamSortMode, + selectedEpisode, + metadata, + ]); + + // Episode image + 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 null; + }, [currentEpisode, episodeThumbnail, tmdbEpisodeOverride?.still_path]); + + // IMDb rating helper + const getIMDbRating = useCallback( + (seasonNumber: number, episodeNumber: number): number | null => { + const key = `${seasonNumber}:${episodeNumber}`; + return imdbRatingsMap[key] ?? null; + }, + [imdbRatingsMap] + ); + + // Effective episode rating + const effectiveEpisodeVote = useMemo(() => { + if (!currentEpisode) return 0; + const imdbRating = getIMDbRating(currentEpisode.season_number, currentEpisode.episode_number); + if (imdbRating !== null) return imdbRating; + const v = (tmdbEpisodeOverride?.vote_average ?? currentEpisode.vote_average) || 0; + return typeof v === 'number' ? v : Number(v) || 0; + }, [currentEpisode, tmdbEpisodeOverride?.vote_average, getIMDbRating]); + + // Check if has IMDb rating + const hasIMDbRating = useMemo(() => { + if (!currentEpisode) return false; + return getIMDbRating(currentEpisode.season_number, currentEpisode.episode_number) !== null; + }, [currentEpisode, getIMDbRating]); + + // Effective runtime + const effectiveEpisodeRuntime = useMemo(() => { + if (!currentEpisode) return undefined; + return (tmdbEpisodeOverride?.runtime ?? (currentEpisode as any).runtime) as number | undefined; + }, [currentEpisode, tmdbEpisodeOverride?.runtime]); + + // Mobile backdrop source + const mobileBackdropSource = useMemo(() => { + if (type === 'series' || (type === 'other' && selectedEpisode)) { + if (episodeImage) return episodeImage; + if (bannerImage) return bannerImage; + } + if (type === 'movie') { + if (bannerImage) return bannerImage; + } + return bannerImage || episodeImage; + }, [type, selectedEpisode, episodeImage, bannerImage]); + + // Color extraction source + const colorExtractionSource = useMemo(() => { + if (!settings.enableStreamsBackdrop) return null; + if (type === 'series' || (type === 'other' && selectedEpisode)) { + return episodeImage || null; + } + return null; + }, [type, selectedEpisode, episodeImage, settings.enableStreamsBackdrop]); + + // Dominant color + const { dominantColor } = useDominantColor(colorExtractionSource); + + // Gradient colors + const createGradientColors = useCallback( + (baseColor: string | null): [string, string, string, string, string] => { + if (settings.enableStreamsBackdrop) { + return ['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.6)', 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)']; + } + + const themeBg = colors.darkBackground; + if (themeBg.startsWith('#')) { + const r = parseInt(themeBg.substr(1, 2), 16); + const g = parseInt(themeBg.substr(3, 2), 16); + const b = parseInt(themeBg.substr(5, 2), 16); + return [ + `rgba(${r},${g},${b},0)`, + `rgba(${r},${g},${b},0.3)`, + `rgba(${r},${g},${b},0.6)`, + `rgba(${r},${g},${b},0.85)`, + `rgba(${r},${g},${b},0.95)`, + ]; + } + + if (!baseColor || baseColor === '#1a1a1a') { + return ['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.6)', 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)']; + } + + const r = parseInt(baseColor.substr(1, 2), 16); + const g = parseInt(baseColor.substr(3, 2), 16); + const b = parseInt(baseColor.substr(5, 2), 16); + + return [ + `rgba(${r},${g},${b},0)`, + `rgba(${r},${g},${b},0.3)`, + `rgba(${r},${g},${b},0.6)`, + `rgba(${r},${g},${b},0.85)`, + `rgba(${r},${g},${b},0.95)`, + ]; + }, + [settings.enableStreamsBackdrop, colors.darkBackground] + ); + + const gradientColors = useMemo(() => createGradientColors(dominantColor), [dominantColor, createGradientColors]); + + // Loading states + const isLoading = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? loadingEpisodeStreams : loadingStreams; + const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; + + const streamsEmpty = + Object.keys(streams).length === 0 || + Object.values(streams).every(provider => !provider.streams || provider.streams.length === 0); + const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0; + const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000); + const showStillFetching = streamsEmpty && loadElapsed >= 10000; + + return { + // Route params + id, + type, + episodeId, + episodeThumbnail, + fromPlayer, + + // Theme + currentTheme, + colors, + settings, + + // Navigation + navigation, + handleBack, + + // Tablet + isTablet, + + // Alert + alertVisible, + alertTitle, + alertMessage, + alertActions, + openAlert, + closeAlert, + + // Metadata + metadata, + imdbId, + bannerImage, + currentEpisode, + groupedEpisodes, + + // Streams + streams, + groupedStreams, + episodeStreams, + sections, + filterItems, + selectedProvider, + handleProviderChange, + handleStreamPress, + + // Loading states + isLoading, + loadingStreams, + loadingEpisodeStreams, + loadingProviders, + streamsEmpty, + showInitialLoading, + showStillFetching, + showNoSourcesError, + hasStremioStreamProviders, + + // Autoplay + isAutoplayWaiting, + autoplayTriggered, + + // Scrapers + activeFetchingScrapers, + scraperLogos, + + // Movie + movieLogoError, + setMovieLogoError, + + // Episode + episodeImage, + effectiveEpisodeVote, + effectiveEpisodeRuntime, + hasIMDbRating, + tmdbEpisodeOverride, + selectedEpisode, + + // Backdrop + mobileBackdropSource, + gradientColors, + dominantColor, + }; +}; diff --git a/src/screens/streams/utils.ts b/src/screens/streams/utils.ts new file mode 100644 index 0000000..751cf76 --- /dev/null +++ b/src/screens/streams/utils.ts @@ -0,0 +1,203 @@ +import { Stream } from '../../types/metadata'; +import { MKV_HEAD_TIMEOUT_MS } from './constants'; + +/** + * Language variations for filtering + */ +const LANGUAGE_VARIATIONS: Record = { + latin: ['latino', 'latina', 'lat'], + spanish: ['espaรฑol', 'espanol', 'spa'], + german: ['deutsch', 'ger'], + french: ['franรงais', 'francais', 'fre'], + portuguese: ['portuguรชs', 'portugues', 'por'], + italian: ['ita'], + english: ['eng'], + japanese: ['jap'], + korean: ['kor'], + chinese: ['chi', 'cn'], + arabic: ['ara'], + russian: ['rus'], + turkish: ['tur'], + hindi: ['hin'], +}; + +/** + * Get all variations of a language name + */ +const getLanguageVariations = (language: string): string[] => { + const langLower = language.toLowerCase(); + const variations = [langLower]; + + if (LANGUAGE_VARIATIONS[langLower]) { + variations.push(...LANGUAGE_VARIATIONS[langLower]); + } + + return variations; +}; + +/** + * Filter streams by excluded quality settings + */ +export const filterStreamsByQuality = ( + streams: Stream[], + excludedQualities: string[] +): Stream[] => { + if (!excludedQualities || excludedQualities.length === 0) { + return streams; + } + + return streams.filter(stream => { + const streamTitle = stream.title || stream.name || ''; + + const hasExcludedQuality = excludedQualities.some(excludedQuality => { + if (excludedQuality === 'Auto') { + return /\b(auto|adaptive)\b/i.test(streamTitle); + } else { + const pattern = new RegExp(excludedQuality.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + return pattern.test(streamTitle); + } + }); + + return !hasExcludedQuality; + }); +}; + +/** + * Filter streams by excluded language settings + */ +export const filterStreamsByLanguage = ( + streams: Stream[], + excludedLanguages: string[] +): Stream[] => { + if (!excludedLanguages || excludedLanguages.length === 0) { + return streams; + } + + return streams.filter(stream => { + const streamName = stream.name || ''; + const streamTitle = stream.title || ''; + const streamDescription = stream.description || ''; + const searchText = `${streamName} ${streamTitle} ${streamDescription}`.toLowerCase(); + + const hasExcludedLanguage = excludedLanguages.some(excludedLanguage => { + const variations = getLanguageVariations(excludedLanguage); + return variations.some(variant => searchText.includes(variant)); + }); + + return !hasExcludedLanguage; + }); +}; + +/** + * Extract numeric quality from stream title + */ +export 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; +}; + +/** + * Sort streams by quality (highest first) + */ +export const sortStreamsByQuality = (streams: Stream[]): Stream[] => { + return [...streams].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; + if (!isAutoA && isAutoB) return 1; + + const qualityA = getQualityNumeric(a.name || a.title); + const qualityB = getQualityNumeric(b.name || b.title); + + 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); + }); +}; + +/** + * Detect MKV format via HEAD request + */ +export const detectMkvViaHead = async ( + url: string, + headers?: Record +): Promise => { + 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); + } +}; + +/** + * Infer video type from URL + */ +export const inferVideoTypeFromUrl = (url?: string): string | undefined => { + if (!url) return undefined; + const lower = url.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; +}; + +/** + * Filter headers for Vidrock compatibility + */ +export const filterHeadersForVidrock = ( + headers: Record | undefined +): Record | undefined => { + if (!headers) return undefined; + + const essentialHeaders: Record = {}; + if (headers['User-Agent']) essentialHeaders['User-Agent'] = headers['User-Agent']; + if (headers['Referer']) essentialHeaders['Referer'] = headers['Referer']; + if (headers['Origin']) essentialHeaders['Origin'] = headers['Origin']; + + return Object.keys(essentialHeaders).length > 0 ? essentialHeaders : undefined; +};