NuvioStreaming/src/screens/StreamsScreen.tsx
2025-11-02 22:30:15 +05:30

2850 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/localScraperService';
import { VideoPlayerService } from '../services/videoPlayerService';
import { useSettings } from '../hooks/useSettings';
import QualityBadge from '../components/metadata/QualityBadge';
import { logger } from '../utils/logger';
import { isMkvStream } from '../utils/mkvDetection';
import CustomAlert from '../components/CustomAlert';
import { 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<string, string>();
let scraperLogoCachePromise: Promise<void> | 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<string, string>) => {
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 (
<View style={[styles.chip, { backgroundColor: color }]}>
<Text style={styles.chipText}>{text}</Text>
</View>
);
});
export const StreamsScreen = () => {
const insets = useSafeAreaInsets();
const route = useRoute<RouteProp<RootStackParamList, 'Streams'>>();
const navigation = useNavigation<RootStackNavigationProp>();
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
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => {
setDimensions(window);
});
return () => subscription?.remove();
}, []);
const deviceWidth = dimensions.width;
const isTablet = deviceWidth >= 768;
// 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<Array<{ label: string; onPress: () => 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<number | null>(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<Set<string>>(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<Record<string, string>>({});
// 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<string, string> = {};
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<string, string> = {};
// No direct way to iterate Map keys safely without exposing it; copy known ids on demand during render
setScraperLogos(prev => prev); // no-op to ensure consistency
}).catch(() => {});
}
};
preloadScraperLogos();
}, []);
// Monitor streams loading and update available providers immediately
useEffect(() => {
// Skip processing if component is unmounting
if (!isMounted.current) return;
const currentStreamsData = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams;
if (__DEV__) console.log('[StreamsScreen] streams state changed', { providerKeys: Object.keys(currentStreamsData || {}), type });
// Update available providers immediately when streams change
const providersWithStreams = Object.entries(currentStreamsData)
.filter(([_, data]) => data.streams && data.streams.length > 0)
.map(([providerId]) => providerId);
if (providersWithStreams.length > 0) {
logger.log(`📊 Providers with streams: ${providersWithStreams.join(', ')}`);
const providersWithStreamsSet = new Set(providersWithStreams);
// Only update if we have new providers, don't remove existing ones during loading
setAvailableProviders(prevProviders => {
const newProviders = new Set([...prevProviders, ...providersWithStreamsSet]);
if (__DEV__) console.log('[StreamsScreen] availableProviders ->', Array.from(newProviders));
return newProviders;
});
}
// Update loading states for individual providers
const expectedProviders = ['stremio'];
const now = Date.now();
setLoadingProviders(prevLoading => {
const nextLoading = { ...prevLoading };
let changed = false;
expectedProviders.forEach(providerId => {
const 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
const hasStremioProviders = await stremioService.hasStreamProviders();
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders);
// Check for local scrapers (only if enabled in settings)
const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers();
if (__DEV__) console.log('[StreamsScreen] hasLocalScrapers:', hasLocalScrapers, 'enableLocalScrapers:', settings.enableLocalScrapers);
// We have providers if we have Stremio addons OR enabled local scrapers
// Note: Cached results do NOT count as active providers - they are just old data
const hasProviders = hasStremioProviders || hasLocalScrapers;
logger.log(`[StreamsScreen] provider check: hasProviders=${hasProviders} (stremio:${hasStremioProviders}, local:${hasLocalScrapers})`);
if (!isMounted.current) return;
setHasStreamProviders(hasProviders);
setHasStremioStreamProviders(hasStremioProviders);
if (!hasProviders) {
logger.log('[StreamsScreen] No providers detected; showing no-sources UI');
const timer = setTimeout(() => {
if (isMounted.current) setShowNoSourcesError(true);
}, 500);
return () => clearTimeout(timer);
} else {
// Removed cached streams pre-display logic
// For series episodes, do not wait for metadata; load directly when episodeId is present
if (episodeId) {
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
setLoadingProviders({
'stremio': true
});
setSelectedEpisode(episodeId);
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId);
loadEpisodeStreams(episodeId);
} else if (type === 'movie') {
logger.log(`🎬 Loading movie streams for: ${id}`);
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id);
loadStreams();
} else if (type === 'tv') {
// TV/live content fetch streams directly
logger.log(`📺 Loading TV streams for: ${id}`);
setLoadingProviders({
'stremio': true
});
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id);
loadStreams();
} else {
// Fallback: series without explicit episodeId (or other types) fetch streams directly
logger.log(`🎬 Loading streams for: ${id}`);
setLoadingProviders({
'stremio': true
});
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id);
loadStreams();
}
// Reset autoplay state when content changes
setAutoplayTriggered(false);
if (settings.autoplayBestStream && !fromPlayer) {
setIsAutoplayWaiting(true);
logger.log('🔄 Autoplay enabled, waiting for best stream...');
} else {
setIsAutoplayWaiting(false);
if (fromPlayer) {
logger.log('🚫 Autoplay disabled: returning from player');
}
}
}
} finally {
isLoadingStreamsRef.current = false;
}
};
checkProviders();
}, [type, id, episodeId, settings.autoplayBestStream, fromPlayer]);
// Memoize handlers
const handleBack = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
(navigation as any).navigate('MainTabs');
}
}, [navigation]);
const handleProviderChange = useCallback((provider: string) => {
setSelectedProvider(provider);
}, []);
// Helper function to filter streams by quality exclusions
const filterStreamsByQuality = useCallback((streams: Stream[]) => {
if (!settings.excludedQualities || settings.excludedQualities.length === 0) {
return streams;
}
return streams.filter(stream => {
const streamTitle = stream.title || stream.name || '';
// Check if any excluded quality is found in the stream title
const hasExcludedQuality = settings.excludedQualities.some(excludedQuality => {
if (excludedQuality === 'Auto') {
// Special handling for Auto quality - check for Auto or Adaptive
return /\b(auto|adaptive)\b/i.test(streamTitle);
} else {
// Create a case-insensitive regex pattern for other qualities
const pattern = new RegExp(excludedQuality.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
return pattern.test(streamTitle);
}
});
// Return true to keep the stream (if it doesn't have excluded quality)
return !hasExcludedQuality;
});
}, [settings.excludedQualities]);
// 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<string, any[]> = 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?: { forceVlc?: boolean; headers?: Record<string, string> }) => {
// Filter headers for Vidrock - only send essential headers
const filterHeadersForVidrock = (headers: Record<string, string> | undefined): Record<string, string> | undefined => {
if (!headers) return undefined;
// Only keep essential headers for Vidrock
const essentialHeaders: Record<string, string> = {};
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;
};
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
});
// Add 50ms delay before navigating to player
await new Promise(resolve => setTimeout(resolve, 50));
// Prepare available streams for the change source feature
const streamsToPass = (type === 'series' || (type === 'other' && selectedEpisode)) ? episodeStreams : groupedStreams;
// Determine the stream name using the same logic as StreamCard
const streamName = stream.name || stream.title || 'Unnamed Stream';
const streamProvider = stream.addonId || stream.addonName || stream.name;
// Do NOT pre-force VLC. Let ExoPlayer try first; fallback occurs on decoder error in the player.
let forceVlc = !!options?.forceVlc;
// 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';
navigation.navigate(playerRoute as any, {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined,
season: (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined,
episode: (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined,
quality: (stream.title?.match(/(\d+)p/) || [])[1] || undefined,
year: metadata?.year,
streamProvider: streamProvider,
streamName: streamName,
// Use filtered headers for Vidrock compatibility
headers: finalHeaders,
// Android will use this to choose VLC path; iOS ignores
forceVlc,
id,
type,
episodeId: (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined,
imdbId: imdbId || undefined,
availableStreams: streamsToPass,
backdrop: bannerImage,
// 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<boolean>([
detectMkvViaHead(stream.url, (stream.headers as any) || undefined),
new Promise<boolean>(res => setTimeout(() => res(false), MKV_HEAD_TIMEOUT_MS)),
]);
if (mkvDetected) {
const mergedHeaders = {
...(stream.headers || {}),
'Content-Type': 'video/x-matroska',
} as Record<string, string>;
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
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 (
<View style={styles.sectionHeaderContainer}>
<View style={styles.sectionHeaderContent}>
<Text style={styles.streamGroupTitle}>{section.title}</Text>
{isProviderLoading && (
<View style={styles.sectionLoadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.sectionLoadingText, { color: colors.primary }]}>
Loading...
</Text>
</View>
)}
</View>
</View>
);
}, [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 (
<PaperProvider>
<View style={styles.container}>
<StatusBar
translucent
backgroundColor="transparent"
barStyle="light-content"
/>
{Platform.OS !== 'ios' && (
<View
style={[styles.backButtonContainer, isTablet && styles.backButtonContainerTablet]}
>
<TouchableOpacity
style={[
styles.backButton,
Platform.OS === 'android' ? { paddingTop: 45 } : null
]}
onPress={handleBack}
activeOpacity={0.7}
>
<MaterialIcons name="arrow-back" size={24} color={colors.white} />
<Text style={styles.backButtonText}>
{metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? 'Back to Episodes' : 'Back to Info'}
</Text>
</TouchableOpacity>
</View>
)}
{isTablet ? (
<TabletStreamsLayout
episodeImage={episodeImage}
bannerImage={bannerImage}
metadata={metadata}
type={type}
currentEpisode={currentEpisode}
movieLogoError={movieLogoError}
setMovieLogoError={setMovieLogoError}
streamsEmpty={streamsEmpty}
selectedProvider={selectedProvider}
filterItems={filterItems}
handleProviderChange={handleProviderChange}
activeFetchingScrapers={activeFetchingScrapers}
isAutoplayWaiting={isAutoplayWaiting}
autoplayTriggered={autoplayTriggered}
showNoSourcesError={showNoSourcesError}
showInitialLoading={showInitialLoading}
showStillFetching={showStillFetching}
sections={sections}
renderSectionHeader={renderSectionHeader}
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}
/>
) : (
// PHONE LAYOUT (existing structure)
<>
{/* Full Screen Background for Mobile */}
{settings.enableStreamsBackdrop ? (
<View style={StyleSheet.absoluteFill}>
{mobileBackdropSource ? (
<AnimatedImage
source={{ uri: mobileBackdropSource }}
style={styles.mobileFullScreenBackground}
contentFit="cover"
/>
) : (
<View style={styles.mobileNoBackdropBackground} />
)}
{Platform.OS === 'android' && AndroidBlurView ? (
<AndroidBlurView
blurAmount={15}
blurRadius={25}
overlayColor={"rgba(0,0,0,0.85)"}
style={StyleSheet.absoluteFill}
/>
) : (
<ExpoBlurView
intensity={60}
tint="dark"
style={StyleSheet.absoluteFill}
/>
)}
{/* Dark overlay to reduce brightness */}
{Platform.OS === 'ios' && (
<View style={[
StyleSheet.absoluteFill,
{ backgroundColor: 'rgba(0,0,0,0.8)' }
]} />
)}
</View>
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.darkBackground }]} />
)}
{type === 'movie' && metadata && (
<View style={[
styles.movieTitleContainer,
!settings.enableStreamsBackdrop && { backgroundColor: colors.darkBackground }
]}>
<View style={styles.movieTitleContent}>
{metadata.logo && !movieLogoError ? (
<FastImage
source={{ uri: metadata.logo }}
style={styles.movieLogo}
resizeMode={FastImage.resizeMode.contain}
onError={() => setMovieLogoError(true)}
/>
) : (
<AnimatedText style={styles.movieTitle} numberOfLines={2}>
{metadata.name}
</AnimatedText>
)}
</View>
</View>
)}
{currentEpisode && (
<View style={[
styles.streamsHeroContainer,
!settings.enableStreamsBackdrop && { backgroundColor: colors.darkBackground }
]}>
<View style={StyleSheet.absoluteFill}>
<View
style={StyleSheet.absoluteFill}
>
<AnimatedImage
source={episodeImage ? { uri: episodeImage } : undefined}
style={styles.streamsHeroBackground}
contentFit="cover"
/>
<LinearGradient
colors={gradientColors}
locations={[0, 0.4, 0.6, 0.8, 1]}
style={styles.streamsHeroGradient}
>
<View style={styles.streamsHeroContent}>
{currentEpisode ? (
<View style={styles.streamsHeroInfo}>
<AnimatedText style={styles.streamsHeroEpisodeNumber} delay={50}>
{currentEpisode.episodeString}
</AnimatedText>
<AnimatedText style={styles.streamsHeroTitle} numberOfLines={1} delay={100}>
{currentEpisode.name}
</AnimatedText>
{!!currentEpisode.overview && (
<AnimatedView delay={150}>
<Text style={styles.streamsHeroOverview} numberOfLines={2}>
{currentEpisode.overview}
</Text>
</AnimatedView>
)}
<AnimatedView style={styles.streamsHeroMeta} delay={200}>
<Text style={styles.streamsHeroReleased}>
{tmdbService.formatAirDate(currentEpisode.air_date)}
</Text>
{effectiveEpisodeVote > 0 && (
<View style={styles.streamsHeroRating}>
{hasIMDbRating ? (
<>
<FastImage source={{ uri: IMDb_LOGO }} style={styles.imdbLogo} resizeMode={FastImage.resizeMode.contain} />
<Text style={[styles.streamsHeroRatingText, { color: '#F5C518' }]}>
{effectiveEpisodeVote.toFixed(1)}
</Text>
</>
) : (
<>
<FastImage source={{ uri: TMDB_LOGO }} style={styles.tmdbLogo} resizeMode={FastImage.resizeMode.contain} />
<Text style={styles.streamsHeroRatingText}>
{effectiveEpisodeVote.toFixed(1)}
</Text>
</>
)}
</View>
)}
{!!effectiveEpisodeRuntime && (
<View style={styles.streamsHeroRuntime}>
<MaterialIcons name="schedule" size={16} color={colors.mediumEmphasis} />
<Text style={styles.streamsHeroRuntimeText}>
{effectiveEpisodeRuntime >= 60
? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m`
: `${effectiveEpisodeRuntime}m`}
</Text>
</View>
)}
</AnimatedView>
</View>
) : (
// Placeholder to reserve space and avoid layout shift while loading
<View style={{ width: '100%', height: 120 }} />
)}
</View>
</LinearGradient>
</View>
</View>
</View>
)}
{/* Gradient overlay to blend hero section with streams container */}
{metadata?.videos && metadata.videos.length > 1 && selectedEpisode && (
<View style={styles.heroBlendOverlay}>
<LinearGradient
colors={settings.enableStreamsBackdrop ? [
'rgba(0,0,0,0.98)',
'rgba(0,0,0,0.85)',
'transparent'
] : [
colors.darkBackground,
colors.darkBackground,
'transparent'
]}
locations={[0, 0.4, 1]}
style={StyleSheet.absoluteFill}
/>
</View>
)}
<View style={[
styles.streamsMainContent,
type === 'movie' && styles.streamsMainContentMovie,
!settings.enableStreamsBackdrop && { backgroundColor: colors.darkBackground }
]}>
<View style={[styles.filterContainer]}>
{!streamsEmpty && (
<ProviderFilter
selectedProvider={selectedProvider}
providers={filterItems}
onSelect={handleProviderChange}
theme={currentTheme}
/>
)}
</View>
{/* Active Scrapers Status */}
{activeFetchingScrapers.length > 0 && (
<View
style={styles.activeScrapersContainer}
>
<Text style={styles.activeScrapersTitle}>Fetching from:</Text>
<View style={styles.activeScrapersRow}>
{activeFetchingScrapers.map((scraperName, index) => (
<PulsingChip key={scraperName} text={scraperName} delay={index * 200} />
))}
</View>
</View>
)}
{/* Update the streams/loading state display logic */}
{ showNoSourcesError ? (
<View
style={styles.noStreams}
>
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
<Text style={styles.noStreamsText}>No streaming sources available</Text>
<Text style={styles.noStreamsSubText}>
Please add streaming sources in settings
</Text>
<TouchableOpacity
style={styles.addSourcesButton}
onPress={() => navigation.navigate('Addons')}
>
<Text style={styles.addSourcesButtonText}>Add Sources</Text>
</TouchableOpacity>
</View>
) : streamsEmpty ? (
showInitialLoading ? (
<View
style={styles.loadingContainer}
>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>
{isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'}
</Text>
</View>
) : showStillFetching ? (
<View
style={styles.loadingContainer}
>
<MaterialIcons name="hourglass-bottom" size={32} color={colors.primary} />
<Text style={styles.loadingText}>Still fetching streams</Text>
</View>
) : (
// No streams and not loading = no streams available
<View
style={styles.noStreams}
>
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
<Text style={styles.noStreamsText}>No streams available</Text>
</View>
)
) : (
// Show streams immediately when available, even if still loading others
<View collapsable={false} style={{ flex: 1 }}>
{/* Show autoplay loading overlay if waiting for autoplay */}
{isAutoplayWaiting && !autoplayTriggered && (
<View
style={styles.autoplayOverlay}
>
<View style={styles.autoplayIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.autoplayText}>Starting best stream...</Text>
</View>
</View>
)}
<ScrollView
style={styles.streamsContent}
contentContainerStyle={[
styles.streamsContainer,
{ paddingBottom: insets.bottom + 100 } // Add safe area + extra padding
]}
showsVerticalScrollIndicator={false}
bounces={true}
overScrollMode="never"
// iOS-specific fixes for navigation transition glitches
{...(Platform.OS === 'ios' && {
// Ensure proper rendering during transitions
removeClippedSubviews: false, // Prevent iOS from clipping views during transitions
// Force hardware acceleration for smoother transitions
scrollEventThrottle: 16,
})}
>
{sections.filter(Boolean).map((section, sectionIndex) => (
<View key={section!.addonId || sectionIndex}>
{/* Section Header */}
{renderSectionHeader({ section: section! })}
{/* Stream Cards using FlatList */}
{section!.data && section!.data.length > 0 ? (
<FlatList
data={section!.data}
keyExtractor={(item, index) => {
if (item && item.url) {
return `${item.url}-${sectionIndex}-${index}`;
}
return `empty-${sectionIndex}-${index}`;
}}
renderItem={({ item, index }) => (
<View>
<StreamCard
stream={item}
onPress={() => 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}
/>
</View>
)}
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}
</View>
))}
{/* Footer Loading */}
{(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
<View style={styles.footerLoading}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
</View>
)}
</ScrollView>
</View>
)}
</View>
</>
)}
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
</View>
</PaperProvider>
);
};
// 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);