NuvioStreaming_backup_24-10-25/src/screens/StreamsScreen.tsx
2025-09-29 16:53:10 +05:30

2564 lines
88 KiB
TypeScript

import React, { useCallback, useMemo, memo, useState, useEffect, useRef, useLayoutEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
FlatList,
SectionList,
Platform,
ImageBackground,
ScrollView,
StatusBar,
Dimensions,
Linking,
Clipboard,
Image as RNImage,
} from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
runOnJS
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as ScreenOrientation from 'expo-screen-orientation';
import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Image } from 'expo-image';
import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator';
import { useMetadata } from '../hooks/useMetadata';
import { useMetadataAssets } from '../hooks/useMetadataAssets';
import { useTheme } from '../contexts/ThemeContext';
import { useTrailer } from '../contexts/TrailerContext';
import { Stream } from '../types/metadata';
import { tmdbService } from '../services/tmdbService';
import { stremioService } from '../services/stremioService';
import { localScraperService } from '../services/localScraperService';
import { VideoPlayerService } from '../services/videoPlayerService';
import { useSettings } from '../hooks/useSettings';
import QualityBadge from '../components/metadata/QualityBadge';
import { logger } from '../utils/logger';
import { isMkvStream } from '../utils/mkvDetection';
import CustomAlert from '../components/CustomAlert';
import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
import { useDownloads } from '../contexts/DownloadsContext';
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png';
const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_Vision_%28logo%29.svg/512px-Dolby_Vision_%28logo%29.svg.png?20220908042900';
const { width, height } = Dimensions.get('window');
// Cache for scraper logos to avoid repeated async calls
const scraperLogoCache = new Map<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
const AnimatedImage = memo(({
source,
style,
contentFit,
onLoad
}: {
source: { uri: string } | undefined;
style: any;
contentFit: any;
onLoad?: () => void;
}) => {
const opacity = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
useEffect(() => {
if (source?.uri) {
opacity.value = withTiming(1, { duration: 300 });
} else {
opacity.value = 0;
}
}, [source?.uri]);
// Cleanup on unmount
useEffect(() => {
return () => {
opacity.value = 0;
};
}, []);
return (
<Animated.View style={[style, animatedStyle]}>
<Image
source={source}
style={StyleSheet.absoluteFillObject}
contentFit={contentFit}
onLoad={onLoad}
/>
</Animated.View>
);
});
const AnimatedText = memo(({
children,
style,
delay = 0,
numberOfLines
}: {
children: React.ReactNode;
style: any;
delay?: number;
numberOfLines?: number;
}) => {
const opacity = useSharedValue(0);
const translateY = useSharedValue(20);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ translateY: translateY.value }],
}));
useEffect(() => {
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
}, [delay]);
// Cleanup on unmount
useEffect(() => {
return () => {
opacity.value = 0;
translateY.value = 20;
};
}, []);
return (
<Animated.Text style={[style, animatedStyle]} numberOfLines={numberOfLines}>
{children}
</Animated.Text>
);
});
const AnimatedView = memo(({
children,
style,
delay = 0
}: {
children: React.ReactNode;
style?: any;
delay?: number;
}) => {
const opacity = useSharedValue(0);
const translateY = useSharedValue(20);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ translateY: translateY.value }],
}));
useEffect(() => {
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
}, [delay]);
// Cleanup on unmount
useEffect(() => {
return () => {
opacity.value = 0;
translateY.value = 20;
};
}, []);
return (
<Animated.View style={[style, animatedStyle]}>
{children}
</Animated.View>
);
});
// Extracted Components
const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos, scraperLogo, showAlert, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName }: {
stream: Stream;
onPress: () => void;
index: number;
isLoading?: boolean;
statusMessage?: string;
theme: any;
showLogos?: boolean;
scraperLogo?: string | null;
showAlert: (title: string, message: string) => void;
parentTitle?: string;
parentType?: 'movie' | 'series';
parentSeason?: number;
parentEpisode?: number;
parentEpisodeTitle?: string;
parentPosterUrl?: string | null;
providerName?: string;
}) => {
const { startDownload } = useDownloads();
// Handle long press to copy stream URL to clipboard
const handleLongPress = useCallback(async () => {
if (stream.url) {
try {
await Clipboard.setString(stream.url);
// Use toast for Android, custom alert for iOS
if (Platform.OS === 'android') {
toast('Stream URL copied to clipboard!', {
duration: 2000,
position: ToastPosition.BOTTOM,
});
} else {
// iOS uses custom alert
setTimeout(() => {
showAlert('Copied!', 'Stream URL has been copied to clipboard.');
}, 50);
}
} catch (error) {
// Fallback: show URL in alert if clipboard fails
if (Platform.OS === 'android') {
toast(`Stream URL: ${stream.url}`, {
duration: 3000,
position: ToastPosition.BOTTOM,
});
} else {
setTimeout(() => {
showAlert('Stream URL', stream.url);
}, 50);
}
}
}
}, [stream.url, showAlert]);
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
const streamInfo = useMemo(() => {
const title = stream.title || '';
const name = stream.name || '';
// Helper function to format size from bytes
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Get size from title (legacy format) or from stream.size field
let sizeDisplay = title.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) {
sizeDisplay = formatSize(stream.size);
}
// Extract quality for badge display
const basicQuality = title.match(/(\d+)p/)?.[1] || null;
return {
quality: basicQuality,
isHDR: title.toLowerCase().includes('hdr'),
isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'),
size: sizeDisplay,
isDebrid: stream.behaviorHints?.cached,
displayName: name || 'Unnamed Stream',
subTitle: title && title !== name ? title : null
};
}, [stream.name, stream.title, stream.behaviorHints, stream.size]);
// Logo is provided by parent to avoid per-card async work
const handleDownload = useCallback(async () => {
try {
const url = stream.url;
if (!url) return;
const parent: any = stream as any;
const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number);
const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number);
const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name;
// Prefer the stream's display name (often includes provider + resolution)
const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider';
const idForContent = parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
await startDownload({
id: String(idForContent),
type: inferredType,
title: String(inferredTitle),
providerName: String(provider),
season: inferredType === 'series' ? (season ? Number(season) : undefined) : undefined,
episode: inferredType === 'series' ? (episode ? Number(episode) : undefined) : undefined,
episodeTitle: inferredType === 'series' ? (episodeTitle ? String(episodeTitle) : undefined) : undefined,
quality: streamInfo.quality || undefined,
posterUrl: parentPosterUrl || parent.poster || parent.backdrop || null,
url,
headers: (stream.headers as any) || undefined,
});
toast('Download started', { duration: 1500, position: ToastPosition.BOTTOM });
} catch {}
}, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title]);
const isDebrid = streamInfo.isDebrid;
return (
<TouchableOpacity
style={[
styles.streamCard,
isLoading && styles.streamCardLoading,
isDebrid && styles.streamCardHighlighted
]}
onPress={onPress}
onLongPress={handleLongPress}
disabled={isLoading}
activeOpacity={0.7}
>
{/* Scraper Logo */}
{showLogos && scraperLogo && (
<View style={styles.scraperLogoContainer}>
<Image
source={{ uri: scraperLogo }}
style={styles.scraperLogo}
resizeMode="contain"
/>
</View>
)}
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
{streamInfo.displayName}
</Text>
{streamInfo.subTitle && (
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
{streamInfo.subTitle}
</Text>
)}
</View>
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={theme.colors.primary} />
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
{statusMessage || "Loading..."}
</Text>
</View>
)}
</View>
<View style={styles.streamMetaRow}>
{streamInfo.isDolby && (
<QualityBadge type="VISION" />
)}
{streamInfo.size && (
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
</View>
)}
{streamInfo.isDebrid && (
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
</View>
)}
</View>
</View>
<View style={styles.streamAction}>
<MaterialIcons
name="play-arrow"
size={22}
color={theme.colors.white}
/>
</View>
<TouchableOpacity
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
onPress={handleDownload}
activeOpacity={0.7}
>
<MaterialIcons
name="download"
size={20}
color={theme.colors.highEmphasis}
/>
</TouchableOpacity>
</TouchableOpacity>
);
});
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>
);
});
const PulsingChip = memo(({ text, delay }: { text: string; delay: number }) => {
const { currentTheme } = useTheme();
const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]);
// Make chip static to avoid continuous animation load
return (
<View style={styles.activeScraperChip}>
<Text style={styles.activeScraperText}>{text}</Text>
</View>
);
});
const ProviderFilter = memo(({
selectedProvider,
providers,
onSelect,
theme
}: {
selectedProvider: string;
providers: Array<{ id: string; name: string; }>;
onSelect: (id: string) => void;
theme: any;
}) => {
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
<TouchableOpacity
style={[
styles.filterChip,
selectedProvider === item.id && styles.filterChipSelected
]}
onPress={() => onSelect(item.id)}
>
<Text style={[
styles.filterChipText,
selectedProvider === item.id && styles.filterChipTextSelected
]}>
{item.name}
</Text>
</TouchableOpacity>
), [selectedProvider, onSelect, styles]);
return (
<View>
<FlatList
data={providers}
renderItem={renderItem}
keyExtractor={item => item.id}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.filterScroll}
bounces={true}
overScrollMode="never"
decelerationRate="fast"
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={3}
removeClippedSubviews={true}
getItemLayout={(data, index) => ({
length: 100, // Approximate width of each item
offset: 100 * index,
index,
})}
/>
</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();
// Add ref to prevent excessive updates
const isMounted = useRef(true);
const loadStartTimeRef = useRef(0);
const hasDoneInitialLoadRef = 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]);
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 = type === 'series' ? episodeStreams : groupedStreams;
// Update available providers immediately when streams change
const providersWithStreams = Object.entries(currentStreamsData)
.filter(([_, data]) => data.streams && data.streams.length > 0)
.map(([providerId]) => providerId);
if (providersWithStreams.length > 0) {
logger.log(`📊 Providers with streams: ${providersWithStreams.join(', ')}`);
const providersWithStreamsSet = new Set(providersWithStreams);
// Only update if we have new providers, don't remove existing ones during loading
setAvailableProviders(prevProviders => {
const newProviders = new Set([...prevProviders, ...providersWithStreamsSet]);
return newProviders;
});
}
// Update loading states for individual providers
const expectedProviders = ['stremio'];
const now = Date.now();
setLoadingProviders(prevLoading => {
const nextLoading = { ...prevLoading };
let changed = false;
expectedProviders.forEach(providerId => {
const hasStreams = currentStreamsData[providerId] &&
currentStreamsData[providerId].streams &&
currentStreamsData[providerId].streams.length > 0;
const value = (loadingStreams || loadingEpisodeStreams) && !hasStreams;
if (nextLoading[providerId] !== value) {
nextLoading[providerId] = value;
changed = true;
}
});
return changed ? nextLoading : prevLoading;
});
}, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type]);
// Reset the selected provider to 'all' if the current selection is no longer available
// But preserve special filter values like 'grouped-plugins' and 'all'
useEffect(() => {
// Don't reset if it's a special filter value
const isSpecialFilter = selectedProvider === 'all' || selectedProvider === 'grouped-plugins';
if (isSpecialFilter) {
return; // Always preserve special filters
}
// Check if provider exists in current streams data
const currentStreamsData = type === 'series' ? episodeStreams : groupedStreams;
const hasStreamsForProvider = currentStreamsData[selectedProvider] &&
currentStreamsData[selectedProvider].streams &&
currentStreamsData[selectedProvider].streams.length > 0;
// Only reset if the provider doesn't exist in available providers AND doesn't have streams
const isAvailableProvider = availableProviders.has(selectedProvider);
if (!isAvailableProvider && !hasStreamsForProvider) {
setSelectedProvider('all');
}
}, [selectedProvider, availableProviders, episodeStreams, groupedStreams, type]);
// Update useEffect to check for sources
useEffect(() => {
const checkProviders = async () => {
// Check for Stremio addons
const hasStremioProviders = await stremioService.hasStreamProviders();
// Check for local scrapers (only if enabled in settings)
const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers();
// We have providers if we have either Stremio addons OR enabled local scrapers
const hasProviders = hasStremioProviders || hasLocalScrapers;
if (!isMounted.current) return;
setHasStreamProviders(hasProviders);
setHasStremioStreamProviders(hasStremioProviders);
if (!hasProviders) {
const timer = setTimeout(() => {
if (isMounted.current) setShowNoSourcesError(true);
}, 500);
return () => clearTimeout(timer);
} else {
if (type === 'series' && episodeId) {
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
setLoadingProviders({
'stremio': true
});
setSelectedEpisode(episodeId);
setStreamsLoadStart(Date.now());
loadEpisodeStreams(episodeId);
} else if (type === 'movie') {
logger.log(`🎬 Loading movie streams for: ${id}`);
setStreamsLoadStart(Date.now());
loadStreams();
}
// Reset autoplay state when content changes
setAutoplayTriggered(false);
if (settings.autoplayBestStream && !fromPlayer) {
setIsAutoplayWaiting(true);
logger.log('🔄 Autoplay enabled, waiting for best stream...');
} else {
setIsAutoplayWaiting(false);
if (fromPlayer) {
logger.log('🚫 Autoplay disabled: returning from player');
}
}
}
};
checkProviders();
}, [type, id, episodeId, settings.autoplayBestStream, fromPlayer]);
// Memoize handlers
const handleBack = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
(navigation as any).navigate('MainTabs');
}
}, [navigation]);
const handleProviderChange = useCallback((provider: string) => {
setSelectedProvider(provider);
}, []);
// Helper function to filter streams by quality exclusions
const filterStreamsByQuality = useCallback((streams: Stream[]) => {
if (!settings.excludedQualities || settings.excludedQualities.length === 0) {
return streams;
}
return streams.filter(stream => {
const streamTitle = stream.title || stream.name || '';
// Check if any excluded quality is found in the stream title
const hasExcludedQuality = settings.excludedQualities.some(excludedQuality => {
if (excludedQuality === 'Auto') {
// Special handling for Auto quality - check for Auto or Adaptive
return /\b(auto|adaptive)\b/i.test(streamTitle);
} else {
// Create a case-insensitive regex pattern for other qualities
const pattern = new RegExp(excludedQuality.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
return pattern.test(streamTitle);
}
});
// Return true to keep the stream (if it doesn't have excluded quality)
return !hasExcludedQuality;
});
}, [settings.excludedQualities]);
// Note: No additional sorting applied to stream cards; preserve provider order
// Function to determine the best stream based on quality, provider priority, and other factors
const getBestStream = useCallback((streamsData: typeof groupedStreams): Stream | null => {
if (!streamsData || Object.keys(streamsData).length === 0) {
return null;
}
// Helper function to extract quality as number
const getQualityNumeric = (title: string | undefined): number => {
if (!title) return 0;
// Check for 4K first (treat as 2160p)
if (/\b4k\b/i.test(title)) {
return 2160;
}
const matchWithP = title.match(/(\d+)p/i);
if (matchWithP) return parseInt(matchWithP[1], 10);
const qualityPatterns = [
/\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i
];
for (const pattern of qualityPatterns) {
const match = title.match(pattern);
if (match) {
const quality = parseInt(match[1], 10);
if (quality >= 240 && quality <= 8000) return quality;
}
}
return 0;
};
// Provider priority (higher number = higher priority)
const getProviderPriority = (addonId: string): number => {
// Get Stremio addon installation order (earlier = higher priority)
const installedAddons = stremioService.getInstalledAddons();
const addonIndex = installedAddons.findIndex(addon => addon.id === addonId);
if (addonIndex !== -1) {
// Higher priority for addons installed earlier (reverse index)
return 50 - addonIndex;
}
return 0; // Unknown providers get lowest priority
};
// Collect all streams with metadata
const allStreams: Array<{
stream: Stream;
quality: number;
providerPriority: number;
}> = [];
Object.entries(streamsData).forEach(([addonId, { streams }]) => {
// Apply quality filtering to streams before processing
const filteredStreams = filterStreamsByQuality(streams);
filteredStreams.forEach(stream => {
const quality = getQualityNumeric(stream.name || stream.title);
const providerPriority = getProviderPriority(addonId);
allStreams.push({
stream,
quality,
providerPriority,
});
});
});
if (allStreams.length === 0) return null;
// Sort streams by multiple criteria (best first)
allStreams.sort((a, b) => {
// 1. Prioritize higher quality
if (a.quality !== b.quality) {
return b.quality - a.quality;
}
// 2. Prioritize better providers
if (a.providerPriority !== b.providerPriority) {
return b.providerPriority - a.providerPriority;
}
return 0;
});
logger.log(`🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p, Provider Priority: ${allStreams[0].providerPriority})`);
return allStreams[0].stream;
}, [filterStreamsByQuality]);
const currentEpisode = useMemo(() => {
if (!selectedEpisode) return null;
// Search through all episodes in all seasons
const allEpisodes = Object.values(groupedEpisodes).flat();
return allEpisodes.find(ep =>
ep.stremioId === selectedEpisode ||
`${id}:${ep.season_number}:${ep.episode_number}` === selectedEpisode
);
}, [selectedEpisode, groupedEpisodes, id]);
// TMDB hydration for series hero (rating/runtime/still)
const [tmdbEpisodeOverride, setTmdbEpisodeOverride] = useState<{ vote_average?: number; runtime?: number; still_path?: string } | null>(null);
useEffect(() => {
const hydrateEpisodeFromTmdb = async () => {
try {
setTmdbEpisodeOverride(null);
if (type !== 'series' || !currentEpisode || !id) return;
// Skip if data already present
const needsHydration = !(currentEpisode as any).runtime || !(currentEpisode as any).vote_average || !currentEpisode.still_path;
if (!needsHydration) return;
// Resolve TMDB show id
let tmdbShowId: number | null = null;
if (id.startsWith('tmdb:')) {
tmdbShowId = parseInt(id.split(':')[1], 10);
} else if (id.startsWith('tt')) {
tmdbShowId = await tmdbService.findTMDBIdByIMDB(id);
}
if (!tmdbShowId) return;
const allEpisodes: Record<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]);
const navigateToPlayer = useCallback(async (stream: Stream, options?: { forceVlc?: boolean; headers?: Record<string, string> }) => {
// 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' ? 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;
// Decide if we should force VLC on Android based on scraper format or URL/content-type
let forceVlc = !!options?.forceVlc;
try {
const providerId = stream.addonId || (stream as any).addon || '';
// If provider declares MKV support in manifest, prefer VLC
if (Platform.OS === 'android' && providerId) {
const supportsMkv = await localScraperService.supportsFormat(providerId, 'mkv');
if (supportsMkv) forceVlc = true;
}
// URL/content-type heuristic
if (!forceVlc) {
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 contentType = (stream.headers && ((stream.headers as any)['Content-Type'] || (stream.headers as any)['content-type'])) || '';
const isMkvByHeader = typeof contentType === 'string' && /matroska|x-matroska/i.test(contentType);
if (Platform.OS === 'android' && (isMkvByPath || isMkvByHeader)) {
forceVlc = true;
}
}
} catch {}
// 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' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: (stream.title?.match(/(\d+)p/) || [])[1] || undefined,
year: metadata?.year,
streamProvider: streamProvider,
streamName: streamName,
// Always prefer stream.headers; player will use these for requests
headers: options?.headers || stream.headers || undefined,
// Android will use this to choose VLC path; iOS ignores
forceVlc,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
imdbId: imdbId || undefined,
availableStreams: streamsToPass,
backdrop: bannerImage || undefined,
// Hint for Android ExoPlayer/react-native-video
videoType: videoType,
} as any);
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage]);
// Update handleStreamPress
const handleStreamPress = useCallback(async (stream: Stream) => {
try {
if (stream.url) {
// Block magnet links - not supported yet
if (stream.url.startsWith('magnet:')) {
try {
openAlert('Not supported', 'Torrent streaming is not supported yet.');
} catch (_e) {}
return;
}
// If stream is actually MKV format, force the in-app VLC-based player on iOS
try {
if (Platform.OS === 'ios' && settings.preferredPlayer === 'internal') {
// Check if the actual stream is an MKV file
const lowerUri = (stream.url || '').toLowerCase();
// iOS now always uses KSPlayer, no need for format-specific logic
// Keep this for logging purposes only
const contentType = (stream.headers && ((stream.headers as any)['Content-Type'] || (stream.headers as any)['content-type'])) || '';
const isMkvByHeader = typeof contentType === 'string' && contentType.includes('matroska');
const isMkvByPath = lowerUri.includes('.mkv') || /[?&]ext=mkv\b/.test(lowerUri) || /format=mkv\b/.test(lowerUri) || /container=mkv\b/.test(lowerUri);
const isMkvFile = Boolean(isMkvByHeader || isMkvByPath);
if (isMkvFile) {
logger.log(`[StreamsScreen] Stream is MKV format - will play in KSPlayer on iOS`);
}
}
} catch (err) {
logger.warn('[StreamsScreen] Stream format pre-check failed:', err);
}
// iOS: very short MKV detection race; never block longer than MKV_HEAD_TIMEOUT_MS
if (Platform.OS === 'ios' && settings.preferredPlayer === 'internal') {
const lowerUrl = (stream.url || '').toLowerCase();
const isMkvByPath = lowerUrl.includes('.mkv') || /[?&]ext=mkv\b/i.test(lowerUrl) || /format=mkv\b/i.test(lowerUrl) || /container=mkv\b/i.test(lowerUrl);
const isHttp = lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
if (!isMkvByPath && isHttp) {
try {
const mkvDetected = await Promise.race<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;
default:
// If no matching player or the setting is somehow invalid, use internal player
navigateToPlayer(stream);
return;
}
if (__DEV__) console.log(`Attempting to open stream in ${settings.preferredPlayer}`);
// Try each URL format in sequence
const tryNextUrl = (index: number) => {
if (index >= externalPlayerUrls.length) {
if (__DEV__) console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`);
// Try direct URL as last resort
Linking.openURL(stream.url)
.then(() => { if (__DEV__) console.log('Opened with direct URL'); })
.catch(() => {
if (__DEV__) console.log('Direct URL failed, falling back to built-in player');
navigateToPlayer(stream);
});
return;
}
const url = externalPlayerUrls[index];
if (__DEV__) console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`);
Linking.openURL(url)
.then(() => { if (__DEV__) console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`); })
.catch(err => {
if (__DEV__) console.log(`Format ${index + 1} failed: ${err.message}`, err);
tryNextUrl(index + 1);
});
};
// Start with the first URL format
tryNextUrl(0);
} catch (error) {
if (__DEV__) console.error(`Error with ${settings.preferredPlayer}:`, error);
// Fallback to the built-in player
navigateToPlayer(stream);
}
}
// For Android with external player preference
else if (Platform.OS === 'android' && settings.useExternalPlayer) {
try {
if (__DEV__) console.log('Opening stream with Android native app chooser');
// For Android, determine if the URL is a direct http/https URL or a magnet link
const isMagnet = stream.url.startsWith('magnet:');
if (isMagnet) {
// For magnet links, open directly which will trigger the torrent app chooser
if (__DEV__) console.log('Opening magnet link directly');
Linking.openURL(stream.url)
.then(() => { if (__DEV__) console.log('Successfully opened magnet link'); })
.catch(err => {
if (__DEV__) console.error('Failed to open magnet link:', err);
// No good fallback for magnet links
navigateToPlayer(stream);
});
} else {
// For direct video URLs, use the VideoPlayerService to show the Android app chooser
const success = await VideoPlayerService.playVideo(stream.url, {
useExternalPlayer: true,
title: metadata?.name || 'Video',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
episodeNumber: type === 'series' && currentEpisode ? `S${currentEpisode.season_number}E${currentEpisode.episode_number}` : undefined,
});
if (!success) {
if (__DEV__) console.log('VideoPlayerService failed, falling back to built-in player');
navigateToPlayer(stream);
}
}
} catch (error) {
if (__DEV__) console.error('Error with external player:', error);
// Fallback to the built-in player
navigateToPlayer(stream);
}
}
else {
// For internal player or if other options failed, use the built-in player
navigateToPlayer(stream);
}
}
} catch (error) {
if (__DEV__) console.error('Error in handleStreamPress:', error);
// Final fallback: Use built-in player
navigateToPlayer(stream);
}
}, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]);
// Ensure portrait when returning to this screen on iOS
useFocusEffect(
useCallback(() => {
if (Platform.OS === 'ios') {
// Add delay before locking orientation to prevent background glitches
const orientationTimer = setTimeout(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
}, 200); // Small delay to let the screen render properly
// iOS-specific: Force a re-render to prevent background glitches
// This helps ensure the background is properly rendered when returning from player
const renderTimer = setTimeout(() => {
// Trigger a small state update to force re-render
setStreamsLoadStart(prev => prev);
}, 100);
return () => {
clearTimeout(orientationTimer);
clearTimeout(renderTimer);
};
}
return () => {};
}, [])
);
// Autoplay effect - triggers immediately when streams are available and autoplay is enabled
useEffect(() => {
if (
settings.autoplayBestStream &&
!autoplayTriggered &&
isAutoplayWaiting
) {
const streams = type === 'series' ? episodeStreams : groupedStreams;
if (Object.keys(streams).length > 0) {
const bestStream = getBestStream(streams);
if (bestStream) {
logger.log('🚀 Autoplay: Best stream found, starting playback immediately...');
setAutoplayTriggered(true);
setIsAutoplayWaiting(false);
// Start playback immediately - no delay needed
handleStreamPress(bestStream);
} else {
logger.log('⚠️ Autoplay: No suitable stream found');
setIsAutoplayWaiting(false);
}
}
}
}, [
settings.autoplayBestStream,
autoplayTriggered,
isAutoplayWaiting,
type,
episodeStreams,
groupedStreams,
getBestStream,
handleStreamPress
]);
const filterItems = useMemo(() => {
const installedAddons = stremioService.getInstalledAddons();
const streams = type === 'series' ? episodeStreams : groupedStreams;
// Make sure we include all providers with streams, not just those in availableProviders
const allProviders = new Set([
...availableProviders,
...Object.keys(streams).filter(key =>
streams[key] &&
streams[key].streams &&
streams[key].streams.length > 0
)
]);
// In grouped mode, separate addons and plugins
if (settings.streamDisplayMode === 'grouped') {
const addonProviders: string[] = [];
const pluginProviders: string[] = [];
Array.from(allProviders).forEach(provider => {
const isInstalledAddon = installedAddons.some(addon => addon.id === provider);
if (isInstalledAddon) {
addonProviders.push(provider);
} else {
pluginProviders.push(provider);
}
});
const filterChips = [{ id: 'all', name: 'All Providers' }];
// Add individual addon chips
addonProviders
.sort((a, b) => {
const indexA = installedAddons.findIndex(addon => addon.id === a);
const indexB = installedAddons.findIndex(addon => addon.id === b);
return indexA - indexB;
})
.forEach(provider => {
const installedAddon = installedAddons.find(addon => addon.id === provider);
filterChips.push({ id: provider, name: installedAddon?.name || provider });
});
// Add single grouped plugins chip if there are any plugins
if (pluginProviders.length > 0) {
filterChips.push({ id: 'grouped-plugins', name: localScraperService.getRepositoryName() });
}
return filterChips;
}
// Normal mode - individual chips for all providers
return [
{ id: 'all', name: 'All Providers' },
...Array.from(allProviders)
.sort((a, b) => {
// Sort by Stremio addon installation order
const indexA = installedAddons.findIndex(addon => addon.id === a);
const indexB = installedAddons.findIndex(addon => addon.id === b);
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
return 0;
})
.map(provider => {
const addonInfo = streams[provider];
// Standard handling for Stremio addons
const installedAddon = installedAddons.find(addon => addon.id === provider);
let displayName = provider;
if (installedAddon) displayName = installedAddon.name;
else if (addonInfo?.addonName) displayName = addonInfo.addonName;
return { id: provider, name: displayName };
})
];
}, [availableProviders, type, episodeStreams, groupedStreams, settings.streamDisplayMode]);
const sections = useMemo(() => {
const streams = type === 'series' ? episodeStreams : groupedStreams;
const installedAddons = stremioService.getInstalledAddons();
// Filter streams by selected provider
const filteredEntries = Object.entries(streams)
.filter(([addonId]) => {
// If "all" is selected, show all providers
if (selectedProvider === 'all') {
return true;
}
// In grouped mode, handle special 'grouped-plugins' filter
if (settings.streamDisplayMode === 'grouped' && selectedProvider === 'grouped-plugins') {
const isInstalledAddon = installedAddons.some(addon => addon.id === addonId);
return !isInstalledAddon; // Show only plugins (non-installed addons)
}
// Otherwise only show the selected provider
return addonId === selectedProvider;
})
.sort(([addonIdA], [addonIdB]) => {
// Sort by response order (actual order addons responded)
const indexA = addonResponseOrder.indexOf(addonIdA);
const indexB = addonResponseOrder.indexOf(addonIdB);
// If both are in response order, sort by response order
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
// If only one is in response order, prioritize it
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
// If neither is in response order, maintain original order
return 0;
});
// Check if we should group all streams under one section
if (settings.streamDisplayMode === 'grouped') {
// Separate addon and plugin streams - only apply quality filtering/sorting to plugins
const addonStreams: Stream[] = [];
const pluginStreams: Stream[] = [];
let totalOriginalCount = 0;
filteredEntries.forEach(([addonId, { addonName, streams: providerStreams }]) => {
const isInstalledAddon = installedAddons.some(addon => addon.id === addonId);
// Count original streams before filtering
totalOriginalCount += providerStreams.length;
if (isInstalledAddon) {
// For ADDONS: Keep all streams in original order, NO filtering or sorting
addonStreams.push(...providerStreams);
} else {
// For PLUGINS: Apply quality filtering and sorting
const filteredStreams = filterStreamsByQuality(providerStreams);
if (filteredStreams.length > 0) {
pluginStreams.push(...filteredStreams);
}
}
});
const totalStreamsCount = addonStreams.length + pluginStreams.length;
const isEmptyDueToQualityFilter = totalOriginalCount > 0 && totalStreamsCount === 0;
if (isEmptyDueToQualityFilter) {
return [{
title: 'Available Streams',
addonId: 'grouped-all',
data: [{ isEmptyPlaceholder: true } as any],
isEmptyDueToQualityFilter: true
}];
}
// Combine streams: Addons first (unsorted), then sorted plugins
let combinedStreams = [...addonStreams];
// Apply quality sorting to PLUGIN streams when enabled
if (settings.streamSortMode === 'quality-then-scraper' && pluginStreams.length > 0) {
const sortedPluginStreams = [...pluginStreams].sort((a, b) => {
const titleA = (a.name || a.title || '').toLowerCase();
const titleB = (b.name || b.title || '').toLowerCase();
// Check for "Auto" quality - always prioritize it
const isAutoA = /\b(auto|adaptive)\b/i.test(titleA);
const isAutoB = /\b(auto|adaptive)\b/i.test(titleB);
if (isAutoA && !isAutoB) return -1; // Auto comes first
if (!isAutoA && isAutoB) return 1; // Auto comes first
// If both are Auto or both are not Auto, continue with normal sorting
// Helper function to extract quality as number
const getQualityNumeric = (title: string | undefined): number => {
if (!title) return 0;
// Check for 4K first (treat as 2160p)
if (/\b4k\b/i.test(title)) {
return 2160;
}
const matchWithP = title.match(/(\d+)p/i);
if (matchWithP) return parseInt(matchWithP[1], 10);
const qualityPatterns = [
/\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i
];
for (const pattern of qualityPatterns) {
const match = title.match(pattern);
if (match) {
const quality = parseInt(match[1], 10);
if (quality >= 240 && quality <= 8000) return quality;
}
}
return 0;
};
const qualityA = getQualityNumeric(a.name || a.title);
const qualityB = getQualityNumeric(b.name || b.title);
// Sort by quality (highest first)
if (qualityA !== qualityB) {
return qualityB - qualityA;
}
// If quality is the same, sort by provider name, then stream name
const providerA = a.addonId || a.addonName || '';
const providerB = b.addonId || b.addonName || '';
if (providerA !== providerB) {
return providerA.localeCompare(providerB);
}
const nameA = (a.name || a.title || '').toLowerCase();
const nameB = (b.name || b.title || '').toLowerCase();
return nameA.localeCompare(nameB);
});
// Add sorted plugin streams to the combined streams
combinedStreams.push(...sortedPluginStreams);
} else {
// If quality sorting is disabled, just add plugin streams as-is
combinedStreams.push(...pluginStreams);
}
return [{
title: 'Available Streams',
addonId: 'grouped-all',
data: combinedStreams,
isEmptyDueToQualityFilter: false
}];
} else {
// Use separate sections for each provider (current behavior)
return filteredEntries.map(([addonId, { addonName, streams: providerStreams }]) => {
const isInstalledAddon = installedAddons.some(addon => addon.id === addonId);
// Count original streams before filtering
const originalCount = providerStreams.length;
let filteredStreams = providerStreams;
let isEmptyDueToQualityFilter = false;
// Only apply quality filtering to plugins, NOT addons
if (!isInstalledAddon) {
filteredStreams = filterStreamsByQuality(providerStreams);
isEmptyDueToQualityFilter = originalCount > 0 && filteredStreams.length === 0;
}
if (isEmptyDueToQualityFilter) {
return {
title: addonName,
addonId,
data: [{ isEmptyPlaceholder: true } as any],
isEmptyDueToQualityFilter
};
}
let processedStreams = filteredStreams;
// Apply quality sorting for plugins when enabled, but NOT for addons
if (!isInstalledAddon && settings.streamSortMode === 'quality-then-scraper') {
processedStreams = [...filteredStreams].sort((a, b) => {
const titleA = (a.name || a.title || '').toLowerCase();
const titleB = (b.name || b.title || '').toLowerCase();
// Check for "Auto" quality - always prioritize it
const isAutoA = /\b(auto|adaptive)\b/i.test(titleA);
const isAutoB = /\b(auto|adaptive)\b/i.test(titleB);
if (isAutoA && !isAutoB) return -1; // Auto comes first
if (!isAutoA && isAutoB) return 1; // Auto comes first
// If both are Auto or both are not Auto, continue with normal sorting
// Helper function to extract quality as number
const getQualityNumeric = (title: string | undefined): number => {
if (!title) return 0;
// Check for 4K first (treat as 2160p)
if (/\b4k\b/i.test(title)) {
return 2160;
}
const matchWithP = title.match(/(\d+)p/i);
if (matchWithP) return parseInt(matchWithP[1], 10);
const qualityPatterns = [
/\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i
];
for (const pattern of qualityPatterns) {
const match = title.match(pattern);
if (match) {
const quality = parseInt(match[1], 10);
if (quality >= 240 && quality <= 8000) return quality;
}
}
return 0;
};
const qualityA = getQualityNumeric(a.name || a.title);
const qualityB = getQualityNumeric(b.name || b.title);
// Sort by quality (highest first)
if (qualityA !== qualityB) {
return qualityB - qualityA;
}
// If quality is the same, sort by name/title
const nameA = (a.name || a.title || '').toLowerCase();
const nameB = (b.name || b.title || '').toLowerCase();
return nameA.localeCompare(nameB);
});
}
return {
title: addonName,
addonId,
data: processedStreams,
isEmptyDueToQualityFilter: false
};
});
}
}, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, addonResponseOrder, settings.streamSortMode]);
const episodeImage = useMemo(() => {
if (episodeThumbnail) {
if (episodeThumbnail.startsWith('http')) {
return episodeThumbnail;
}
return tmdbService.getImageUrl(episodeThumbnail, 'original');
}
if (!currentEpisode) return null;
const hydratedStill = tmdbEpisodeOverride?.still_path;
if (currentEpisode.still_path || hydratedStill) {
if (currentEpisode.still_path.startsWith('http')) {
return currentEpisode.still_path;
}
const path = currentEpisode.still_path || hydratedStill || '';
return tmdbService.getImageUrl(path, 'original');
}
return metadata?.poster || null;
}, [currentEpisode, metadata, episodeThumbnail, tmdbEpisodeOverride?.still_path]);
// Effective TMDB fields for hero (series)
const effectiveEpisodeVote = useMemo(() => {
if (!currentEpisode) return 0;
const v = (tmdbEpisodeOverride?.vote_average ?? currentEpisode.vote_average) || 0;
return typeof v === 'number' ? v : Number(v) || 0;
}, [currentEpisode, tmdbEpisodeOverride?.vote_average]);
const effectiveEpisodeRuntime = useMemo(() => {
if (!currentEpisode) return undefined as number | undefined;
const r = (tmdbEpisodeOverride?.runtime ?? (currentEpisode as any).runtime) as number | undefined;
return r;
}, [currentEpisode, tmdbEpisodeOverride?.runtime]);
// Prefetch hero/backdrop and title logo when StreamsScreen opens
useEffect(() => {
const urls: string[] = [];
if (episodeImage && typeof episodeImage === 'string') urls.push(episodeImage);
if (bannerImage && typeof bannerImage === 'string') urls.push(bannerImage);
if (metadata && (metadata as any).logo && typeof (metadata as any).logo === 'string') {
urls.push((metadata as any).logo as string);
}
// Deduplicate and prefetch
Array.from(new Set(urls)).forEach(u => {
RNImage.prefetch(u).catch(() => {});
});
}, [episodeImage, bannerImage, metadata]);
const isLoading = type === 'series' ? loadingEpisodeStreams : loadingStreams;
const streams = type === 'series' ? episodeStreams : groupedStreams;
// Determine extended loading phases
const streamsEmpty = Object.keys(streams).length === 0;
const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0;
const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000);
const showStillFetching = streamsEmpty && loadElapsed >= 10000;
const 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 (
<View style={styles.container}>
<StatusBar
translucent
backgroundColor="transparent"
barStyle="light-content"
/>
{Platform.OS !== 'ios' && (
<View
style={[styles.backButtonContainer]}
>
<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}>
{type === 'series' ? 'Back to Episodes' : 'Back to Info'}
</Text>
</TouchableOpacity>
</View>
)}
{type === 'movie' && metadata && (
<View style={[styles.movieTitleContainer]}>
<View style={styles.movieTitleContent}>
{metadata.logo && !movieLogoError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.movieLogo}
contentFit="contain"
onError={() => setMovieLogoError(true)}
/>
) : (
<AnimatedText style={styles.movieTitle} numberOfLines={2}>
{metadata.name}
</AnimatedText>
)}
</View>
</View>
)}
{type === 'series' && (
<View style={[styles.streamsHeroContainer]}>
<View style={StyleSheet.absoluteFill}>
<View
style={StyleSheet.absoluteFill}
>
<AnimatedImage
source={episodeImage ? { uri: episodeImage } : undefined}
style={styles.streamsHeroBackground}
contentFit="cover"
/>
<LinearGradient
colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.5)', 'rgba(0,0,0,0.7)', colors.darkBackground]}
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}>
<Image source={{ uri: TMDB_LOGO }} style={styles.tmdbLogo} contentFit="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>
)}
<View style={[
styles.streamsMainContent,
type === 'movie' && styles.streamsMainContentMovie
]}>
<View style={[styles.filterContainer]}>
{Object.keys(streams).length > 0 && (
<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.map((section, sectionIndex) => (
<View key={section.addonId || sectionIndex}>
{/* Section Header */}
{renderSectionHeader({ 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;
}
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' ? currentEpisode?.season_number : undefined}
parentEpisode={type === 'series' ? currentEpisode?.episode_number : undefined}
parentEpisodeTitle={type === 'series' ? currentEpisode?.name : undefined}
parentPosterUrl={episodeImage || metadata?.poster || undefined}
providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
/>
</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,
})}
/>
) : (
// Empty section placeholder
<View style={styles.emptySectionContainer}>
<View style={styles.emptySectionContent}>
<MaterialIcons name="filter-list-off" size={32} color={colors.mediumEmphasis} />
<Text style={[styles.emptySectionTitle, { color: colors.mediumEmphasis }]}>
No streams available
</Text>
<Text style={[styles.emptySectionSubtitle, { color: colors.textMuted }]}>
All streams were filtered by your quality settings
</Text>
</View>
</View>
)}
</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>
{Platform.OS === 'ios' && (
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
)}
</View>
);
};
// Create a function to generate styles with the current theme colors
const createStyles = (colors: any) => StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.darkBackground,
// iOS-specific fixes for navigation transition glitches
...(Platform.OS === 'ios' && {
// Ensure the background is properly rendered during transitions
opacity: 1,
// Prevent iOS from trying to optimize the background during transitions
shouldRasterizeIOS: false,
// Ensure the view is properly composited
renderToHardwareTextureAndroid: false,
}),
},
backButtonContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 2,
pointerEvents: 'box-none',
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 16,
paddingVertical: 12,
paddingTop: Platform.OS === 'android' ? 45 : 15,
backgroundColor: 'transparent',
},
backButtonText: {
color: colors.highEmphasis,
fontSize: 13,
fontWeight: '600',
},
streamsMainContent: {
flex: 1,
backgroundColor: colors.darkBackground,
paddingTop: 12,
zIndex: 1,
// iOS-specific fixes for navigation transition glitches
...(Platform.OS === 'ios' && {
// Ensure proper rendering during transitions
opacity: 1,
// Prevent iOS optimization that can cause glitches
shouldRasterizeIOS: false,
}),
},
streamsMainContentMovie: {
paddingTop: Platform.OS === 'android' ? 10 : 15,
},
filterContainer: {
paddingHorizontal: 12,
paddingBottom: 8,
},
filterScroll: {
flexGrow: 0,
},
filterChip: {
backgroundColor: colors.elevation2,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
marginRight: 8,
borderWidth: 0,
},
filterChipSelected: {
backgroundColor: colors.primary,
},
filterChipText: {
color: colors.highEmphasis,
fontWeight: '600',
letterSpacing: 0.1,
},
filterChipTextSelected: {
color: colors.white,
fontWeight: '700',
},
streamsContent: {
flex: 1,
width: '100%',
zIndex: 2,
},
streamsContainer: {
paddingHorizontal: 12,
paddingBottom: 20,
width: '100%',
},
streamGroup: {
marginBottom: 24,
width: '100%',
},
streamGroupTitle: {
color: colors.highEmphasis,
fontSize: 14,
fontWeight: '700',
marginBottom: 6,
marginTop: 0,
opacity: 0.9,
backgroundColor: 'transparent',
},
streamCard: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 14,
borderRadius: 12,
marginBottom: 10,
minHeight: 68,
backgroundColor: colors.card,
borderWidth: 0,
width: '100%',
zIndex: 1,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 0,
},
scraperLogoContainer: {
width: 32,
height: 32,
marginRight: 12,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.elevation2,
borderRadius: 6,
},
scraperLogo: {
width: 24,
height: 24,
},
streamCardLoading: {
opacity: 0.7,
},
streamCardHighlighted: {
backgroundColor: colors.elevation2,
shadowOpacity: 0.18,
},
streamDetails: {
flex: 1,
},
streamNameRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
width: '100%',
flexWrap: 'wrap',
gap: 8
},
streamTitleContainer: {
flex: 1,
},
streamName: {
fontSize: 14,
fontWeight: '700',
marginBottom: 2,
lineHeight: 20,
color: colors.highEmphasis,
letterSpacing: 0.1,
},
streamAddonName: {
fontSize: 12,
lineHeight: 18,
color: colors.mediumEmphasis,
marginBottom: 6,
},
streamMetaRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
marginBottom: 6,
alignItems: 'center',
},
chip: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
marginRight: 6,
marginBottom: 6,
backgroundColor: colors.elevation2,
},
chipText: {
color: colors.highEmphasis,
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.2,
},
progressContainer: {
height: 20,
backgroundColor: colors.transparentLight,
borderRadius: 10,
overflow: 'hidden',
marginBottom: 6,
},
progressBar: {
height: '100%',
backgroundColor: colors.primary,
},
progressText: {
color: colors.highEmphasis,
fontSize: 12,
fontWeight: '600',
marginLeft: 8,
},
streamAction: {
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
},
skeletonCard: {
opacity: 0.7,
},
skeletonTitle: {
height: 24,
width: '40%',
backgroundColor: colors.transparentLight,
borderRadius: 4,
marginBottom: 16,
},
skeletonIcon: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.transparentLight,
marginRight: 12,
},
skeletonText: {
height: 16,
borderRadius: 4,
marginBottom: 8,
backgroundColor: colors.transparentLight,
},
skeletonTag: {
width: 60,
height: 20,
borderRadius: 4,
marginRight: 8,
backgroundColor: colors.transparentLight,
},
noStreams: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
noStreamsText: {
color: colors.textMuted,
fontSize: 16,
marginTop: 16,
},
streamsHeroContainer: {
width: '100%',
height: 220, // Fixed height to prevent layout shift
marginBottom: 0,
position: 'relative',
backgroundColor: colors.black,
pointerEvents: 'box-none',
},
streamsHeroBackground: {
width: '100%',
height: '100%',
backgroundColor: colors.black,
},
streamsHeroGradient: {
...StyleSheet.absoluteFillObject,
justifyContent: 'flex-end',
padding: 16,
paddingBottom: 0,
},
streamsHeroContent: {
width: '100%',
},
streamsHeroInfo: {
width: '100%',
},
streamsHeroEpisodeNumber: {
color: colors.primary,
fontSize: 14,
fontWeight: 'bold',
marginBottom: 2,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
streamsHeroTitle: {
color: colors.highEmphasis,
fontSize: 24,
fontWeight: 'bold',
marginBottom: 4,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
},
streamsHeroOverview: {
color: colors.mediumEmphasis,
fontSize: 14,
lineHeight: 20,
marginBottom: 2,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
streamsHeroMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginTop: 0,
},
streamsHeroReleased: {
color: colors.mediumEmphasis,
fontSize: 14,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
streamsHeroRating: {
flexDirection: 'row',
alignItems: 'center',
// chip background removed
marginTop: 0,
},
tmdbLogo: {
width: 20,
height: 14,
},
streamsHeroRatingText: {
color: colors.highEmphasis,
fontSize: 13,
fontWeight: '700',
marginLeft: 4,
},
loadingContainer: {
alignItems: 'center',
paddingVertical: 24,
},
loadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 4,
fontWeight: '500',
},
downloadingIndicator: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.transparentLight,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
marginLeft: 8,
},
downloadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 4,
fontWeight: '500',
},
loadingIndicator: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
marginLeft: 8,
},
footerLoading: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
footerLoadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 8,
fontWeight: '500',
},
movieTitleContainer: {
width: '100%',
height: 140,
backgroundColor: colors.darkBackground,
pointerEvents: 'box-none',
justifyContent: 'center',
paddingTop: Platform.OS === 'android' ? 65 : 35,
},
movieTitleContent: {
width: '100%',
height: 80, // Fixed height for consistent layout
alignItems: 'center',
justifyContent: 'center',
},
movieLogo: {
width: '100%',
height: 80, // Fixed height to match content container
maxWidth: width * 0.85,
},
movieTitle: {
color: colors.highEmphasis,
fontSize: 28,
fontWeight: '900',
textAlign: 'center',
letterSpacing: -0.5,
paddingHorizontal: 20,
},
streamsHeroRuntime: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
// chip background removed
},
streamsHeroRuntimeText: {
color: colors.mediumEmphasis,
fontSize: 13,
fontWeight: '600',
},
sectionHeaderContainer: {
paddingHorizontal: 12,
paddingVertical: 8,
},
sectionHeaderContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionLoadingIndicator: {
flexDirection: 'row',
alignItems: 'center',
},
sectionLoadingText: {
marginLeft: 8,
},
autoplayOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(0,0,0,0.8)',
padding: 16,
alignItems: 'center',
zIndex: 10,
},
autoplayIndicator: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.elevation2,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
},
autoplayText: {
color: colors.primary,
fontSize: 14,
marginLeft: 8,
fontWeight: '600',
},
noStreamsSubText: {
color: colors.mediumEmphasis,
fontSize: 14,
marginTop: 8,
textAlign: 'center',
},
addSourcesButton: {
marginTop: 24,
paddingHorizontal: 20,
paddingVertical: 10,
backgroundColor: colors.primary,
borderRadius: 8,
},
addSourcesButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
},
activeScrapersContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: 'transparent',
marginHorizontal: 16,
marginBottom: 4,
},
activeScrapersTitle: {
color: colors.mediumEmphasis,
fontSize: 12,
fontWeight: '500',
marginBottom: 6,
opacity: 0.8,
},
activeScrapersRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
},
activeScraperChip: {
backgroundColor: colors.elevation2,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
borderWidth: 0,
},
activeScraperText: {
color: colors.mediumEmphasis,
fontSize: 11,
fontWeight: '400',
},
emptySectionContainer: {
padding: 16,
alignItems: 'center',
justifyContent: 'center',
minHeight: 80,
},
emptySectionContent: {
alignItems: 'center',
justifyContent: 'center',
},
emptySectionTitle: {
fontSize: 14,
fontWeight: '600',
marginTop: 8,
textAlign: 'center',
},
emptySectionSubtitle: {
fontSize: 12,
marginTop: 4,
textAlign: 'center',
lineHeight: 16,
},
});
export default memo(StreamsScreen);