diff --git a/src/components/AnimatedImage.tsx b/src/components/AnimatedImage.tsx
new file mode 100644
index 00000000..a0119c9e
--- /dev/null
+++ b/src/components/AnimatedImage.tsx
@@ -0,0 +1,56 @@
+import React, { memo, useEffect } from 'react';
+import { StyleSheet } from 'react-native';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming
+} from 'react-native-reanimated';
+import FastImage from '@d11/react-native-fast-image';
+
+interface AnimatedImageProps {
+ source: { uri: string } | undefined;
+ style: any;
+ contentFit: any;
+ onLoad?: () => void;
+}
+
+const AnimatedImage = memo(({
+ source,
+ style,
+ contentFit,
+ onLoad
+}: AnimatedImageProps) => {
+ 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 (
+
+
+
+ );
+});
+
+export default AnimatedImage;
diff --git a/src/components/AnimatedText.tsx b/src/components/AnimatedText.tsx
new file mode 100644
index 00000000..89d92796
--- /dev/null
+++ b/src/components/AnimatedText.tsx
@@ -0,0 +1,50 @@
+import React, { memo, useEffect } from 'react';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
+ withDelay
+} from 'react-native-reanimated';
+
+interface AnimatedTextProps {
+ children: React.ReactNode;
+ style: any;
+ delay?: number;
+ numberOfLines?: number;
+}
+
+const AnimatedText = memo(({
+ children,
+ style,
+ delay = 0,
+ numberOfLines
+}: AnimatedTextProps) => {
+ const opacity = useSharedValue(0);
+ const translateY = useSharedValue(20);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [{ translateY: translateY.value }],
+ }));
+
+ useEffect(() => {
+ opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
+ translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
+ }, [delay]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ opacity.value = 0;
+ translateY.value = 20;
+ };
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+});
+
+export default AnimatedText;
diff --git a/src/components/AnimatedView.tsx b/src/components/AnimatedView.tsx
new file mode 100644
index 00000000..52c8f376
--- /dev/null
+++ b/src/components/AnimatedView.tsx
@@ -0,0 +1,48 @@
+import React, { memo, useEffect } from 'react';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
+ withDelay
+} from 'react-native-reanimated';
+
+interface AnimatedViewProps {
+ children: React.ReactNode;
+ style?: any;
+ delay?: number;
+}
+
+const AnimatedView = memo(({
+ children,
+ style,
+ delay = 0
+}: AnimatedViewProps) => {
+ const opacity = useSharedValue(0);
+ const translateY = useSharedValue(20);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [{ translateY: translateY.value }],
+ }));
+
+ useEffect(() => {
+ opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
+ translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
+ }, [delay]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ opacity.value = 0;
+ translateY.value = 20;
+ };
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+});
+
+export default AnimatedView;
diff --git a/src/components/ProviderFilter.tsx b/src/components/ProviderFilter.tsx
new file mode 100644
index 00000000..89005d99
--- /dev/null
+++ b/src/components/ProviderFilter.tsx
@@ -0,0 +1,88 @@
+import React, { memo, useCallback } from 'react';
+import { View, Text, StyleSheet, TouchableOpacity, FlatList } from 'react-native';
+
+interface ProviderFilterProps {
+ selectedProvider: string;
+ providers: Array<{ id: string; name: string; }>;
+ onSelect: (id: string) => void;
+ theme: any;
+}
+
+const ProviderFilter = memo(({
+ selectedProvider,
+ providers,
+ onSelect,
+ theme
+}: ProviderFilterProps) => {
+ const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
+
+ const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
+ onSelect(item.id)}
+ >
+
+ {item.name}
+
+
+ ), [selectedProvider, onSelect, styles]);
+
+ return (
+
+ item.id}
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ style={styles.filterScroll}
+ bounces={true}
+ overScrollMode="never"
+ decelerationRate="fast"
+ initialNumToRender={5}
+ maxToRenderPerBatch={3}
+ windowSize={3}
+ removeClippedSubviews={true}
+ getItemLayout={(data, index) => ({
+ length: 100, // Approximate width of each item
+ offset: 100 * index,
+ index,
+ })}
+ />
+
+ );
+});
+
+const createStyles = (colors: any) => StyleSheet.create({
+ 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',
+ },
+});
+
+export default ProviderFilter;
diff --git a/src/components/PulsingChip.tsx b/src/components/PulsingChip.tsx
new file mode 100644
index 00000000..732ce1bc
--- /dev/null
+++ b/src/components/PulsingChip.tsx
@@ -0,0 +1,36 @@
+import React, { memo } from 'react';
+import { View, Text, StyleSheet } from 'react-native';
+import { useTheme } from '../contexts/ThemeContext';
+
+interface PulsingChipProps {
+ text: string;
+ delay: number;
+}
+
+const PulsingChip = memo(({ text, delay }: PulsingChipProps) => {
+ const { currentTheme } = useTheme();
+ const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]);
+ // Make chip static to avoid continuous animation load
+ return (
+
+ {text}
+
+ );
+});
+
+const createStyles = (colors: any) => StyleSheet.create({
+ activeScraperChip: {
+ backgroundColor: colors.elevation2,
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 6,
+ borderWidth: 0,
+ },
+ activeScraperText: {
+ color: colors.mediumEmphasis,
+ fontSize: 11,
+ fontWeight: '400',
+ },
+});
+
+export default PulsingChip;
diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx
new file mode 100644
index 00000000..0a1f1a80
--- /dev/null
+++ b/src/components/StreamCard.tsx
@@ -0,0 +1,373 @@
+import React, { memo, useCallback, useMemo } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ TouchableOpacity,
+ ActivityIndicator,
+ Platform,
+ Clipboard,
+} from 'react-native';
+import { MaterialIcons } from '@expo/vector-icons';
+import FastImage from '@d11/react-native-fast-image';
+import { Stream } from '../types/metadata';
+import QualityBadge from './metadata/QualityBadge';
+import { useSettings } from '../hooks/useSettings';
+import { useDownloads } from '../contexts/DownloadsContext';
+import { useToast } from '../contexts/ToastContext';
+
+interface StreamCardProps {
+ 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;
+ parentId?: string;
+ parentImdbId?: string;
+}
+
+const StreamCard = memo(({
+ stream,
+ onPress,
+ index,
+ isLoading,
+ statusMessage,
+ theme,
+ showLogos,
+ scraperLogo,
+ showAlert,
+ parentTitle,
+ parentType,
+ parentSeason,
+ parentEpisode,
+ parentEpisodeTitle,
+ parentPosterUrl,
+ providerName,
+ parentId,
+ parentImdbId
+}: StreamCardProps) => {
+ const { settings } = useSettings();
+ const { startDownload } = useDownloads();
+ const { showSuccess, showInfo } = useToast();
+
+ // 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') {
+ showSuccess('URL Copied', 'Stream URL copied to clipboard!');
+ } else {
+ // iOS uses custom alert
+ showAlert('Copied!', 'Stream URL has been copied to clipboard.');
+ }
+ } catch (error) {
+ // Fallback: show URL in alert if clipboard fails
+ if (Platform.OS === 'android') {
+ showInfo('Stream URL', `Stream URL: ${stream.url}`);
+ } else {
+ showAlert('Stream URL', stream.url);
+ }
+ }
+ }
+ }, [stream.url, showAlert, showSuccess, showInfo]);
+
+ 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]);
+
+ const handleDownload = useCallback(async () => {
+ try {
+ const url = stream.url;
+ if (!url) return;
+ // Prevent duplicate downloads for the same exact URL
+ try {
+ const downloadsModule = require('../contexts/DownloadsContext');
+ if (downloadsModule && downloadsModule.isDownloadingUrl && downloadsModule.isDownloadingUrl(url)) {
+ showAlert('Already Downloading', 'This download has already started for this exact link.');
+ return;
+ }
+ } catch {}
+ // Show immediate feedback on both platforms
+ showAlert('Starting Download', 'Download will be started.');
+ 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';
+
+ // Use parentId first (from route params), fallback to stream metadata
+ const idForContent = parentId || parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
+
+ // Extract tmdbId if available (from parentId or parent metadata)
+ let tmdbId: number | undefined = undefined;
+ if (parentId && parentId.startsWith('tmdb:')) {
+ tmdbId = parseInt(parentId.split(':')[1], 10);
+ } else if (typeof parent.tmdbId === 'number') {
+ tmdbId = parent.tmdbId;
+ }
+
+ 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,
+ // Pass metadata for progress tracking
+ imdbId: parentImdbId || parent.imdbId || undefined,
+ tmdbId: tmdbId,
+ });
+ showAlert('Download Started', 'Your download has been added to the queue.');
+ } catch {}
+ }, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]);
+
+ const isDebrid = streamInfo.isDebrid;
+ return (
+
+ {/* Scraper Logo */}
+ {showLogos && scraperLogo && (
+
+
+
+ )}
+
+
+
+
+
+ {streamInfo.displayName}
+
+ {streamInfo.subTitle && (
+
+ {streamInfo.subTitle}
+
+ )}
+
+
+ {/* Show loading indicator if stream is loading */}
+ {isLoading && (
+
+
+
+ {statusMessage || "Loading..."}
+
+
+ )}
+
+
+
+ {streamInfo.isDolby && (
+
+ )}
+
+ {streamInfo.size && (
+
+ 💾 {streamInfo.size}
+
+ )}
+
+ {streamInfo.isDebrid && (
+
+ DEBRID
+
+ )}
+
+
+
+
+ {settings?.enableDownloads !== false && (
+
+
+
+ )}
+
+ );
+});
+
+const createStyles = (colors: any) => StyleSheet.create({
+ 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,
+ },
+ loadingIndicator: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 12,
+ marginLeft: 8,
+ },
+ loadingText: {
+ color: colors.primary,
+ fontSize: 12,
+ marginLeft: 4,
+ fontWeight: '500',
+ },
+ streamAction: {
+ width: 30,
+ height: 30,
+ borderRadius: 15,
+ backgroundColor: colors.primary,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
+
+export default StreamCard;
diff --git a/src/components/TabletStreamsLayout.tsx b/src/components/TabletStreamsLayout.tsx
new file mode 100644
index 00000000..0426361b
--- /dev/null
+++ b/src/components/TabletStreamsLayout.tsx
@@ -0,0 +1,624 @@
+import React, { memo } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ActivityIndicator,
+ FlatList,
+ Platform,
+ ScrollView,
+ TouchableOpacity,
+} from 'react-native';
+import { LinearGradient } from 'expo-linear-gradient';
+import FastImage from '@d11/react-native-fast-image';
+import { MaterialIcons } from '@expo/vector-icons';
+import { BlurView as ExpoBlurView } from 'expo-blur';
+
+// 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;
+ }
+}
+
+import { Stream } from '../types/metadata';
+import { RootStackNavigationProp } from '../navigation/AppNavigator';
+import ProviderFilter from './ProviderFilter';
+import PulsingChip from './PulsingChip';
+import StreamCard from './StreamCard';
+import AnimatedImage from './AnimatedImage';
+
+interface TabletStreamsLayoutProps {
+ // Background and content props
+ episodeImage?: string | null;
+ bannerImage?: string | null;
+ metadata?: any;
+ type: string;
+ currentEpisode?: any;
+
+ // Movie logo props
+ movieLogoError: boolean;
+ setMovieLogoError: (error: boolean) => void;
+
+ // Stream-related props
+ streamsEmpty: boolean;
+ selectedProvider: string;
+ filterItems: Array<{ id: string; name: string; }>;
+ handleProviderChange: (provider: string) => void;
+ activeFetchingScrapers: string[];
+
+ // Loading states
+ isAutoplayWaiting: boolean;
+ autoplayTriggered: boolean;
+ showNoSourcesError: boolean;
+ showInitialLoading: boolean;
+ showStillFetching: boolean;
+
+ // Stream rendering props
+ sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>;
+ renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement;
+ handleStreamPress: (stream: Stream) => void;
+ openAlert: (title: string, message: string) => void;
+
+ // Settings and theme
+ settings: any;
+ currentTheme: any;
+ colors: any;
+
+ // Other props
+ navigation: RootStackNavigationProp;
+ insets: any;
+ streams: any;
+ scraperLogos: Record;
+ id: string;
+ imdbId?: string;
+ loadingStreams: boolean;
+ loadingEpisodeStreams: boolean;
+ hasStremioStreamProviders: boolean;
+}
+
+const TabletStreamsLayout: React.FC = ({
+ episodeImage,
+ bannerImage,
+ metadata,
+ type,
+ currentEpisode,
+ movieLogoError,
+ setMovieLogoError,
+ streamsEmpty,
+ selectedProvider,
+ filterItems,
+ handleProviderChange,
+ activeFetchingScrapers,
+ isAutoplayWaiting,
+ autoplayTriggered,
+ showNoSourcesError,
+ showInitialLoading,
+ showStillFetching,
+ sections,
+ renderSectionHeader,
+ handleStreamPress,
+ openAlert,
+ settings,
+ currentTheme,
+ colors,
+ navigation,
+ insets,
+ streams,
+ scraperLogos,
+ id,
+ imdbId,
+ loadingStreams,
+ loadingEpisodeStreams,
+ hasStremioStreamProviders,
+}) => {
+ const styles = React.useMemo(() => createStyles(colors), [colors]);
+
+ const renderStreamContent = () => {
+ if (showNoSourcesError) {
+ return (
+
+
+ No streaming sources available
+
+ Please add streaming sources in settings
+
+ navigation.navigate('Addons')}
+ >
+ Add Sources
+
+
+ );
+ }
+
+ if (streamsEmpty) {
+ if (showInitialLoading || showStillFetching) {
+ return (
+
+
+
+ {isAutoplayWaiting ? 'Finding best stream for autoplay...' :
+ showStillFetching ? 'Still fetching streams…' :
+ 'Finding available streams...'}
+
+
+ );
+ } else {
+ return (
+
+
+ No streams available
+
+ );
+ }
+ }
+
+ return (
+
+ {sections.filter(Boolean).map((section, sectionIndex) => (
+
+ {renderSectionHeader({ section: section! })}
+
+ {section!.data && section!.data.length > 0 ? (
+ {
+ if (item && item.url) {
+ return `${item.url}-${sectionIndex}-${index}`;
+ }
+ return `empty-${sectionIndex}-${index}`;
+ }}
+ renderItem={({ item, index }) => (
+
+ handleStreamPress(item)}
+ index={index}
+ isLoading={false}
+ statusMessage={undefined}
+ theme={currentTheme}
+ showLogos={settings.showScraperLogos}
+ scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogos[(item.addonId || (item as any).addon) as string] || null : null}
+ showAlert={(t, m) => openAlert(t, m)}
+ parentTitle={metadata?.name}
+ parentType={type as 'movie' | 'series'}
+ parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
+ parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
+ parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
+ parentPosterUrl={episodeImage || metadata?.poster || undefined}
+ providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
+ parentId={id}
+ parentImdbId={imdbId || undefined}
+ />
+
+ )}
+ scrollEnabled={false}
+ initialNumToRender={6}
+ maxToRenderPerBatch={2}
+ windowSize={3}
+ removeClippedSubviews={true}
+ showsVerticalScrollIndicator={false}
+ getItemLayout={(data, index) => ({
+ length: 78,
+ offset: 78 * index,
+ index,
+ })}
+ />
+ ) : null}
+
+ ))}
+
+ {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
+
+
+ Loading more sources...
+
+ )}
+
+ );
+ };
+
+ return (
+
+ {/* Full Screen Background */}
+
+
+
+ {/* Left Panel: Movie Logo/Episode Info */}
+
+ {type === 'movie' && metadata && (
+
+ {metadata.logo && !movieLogoError ? (
+ setMovieLogoError(true)}
+ />
+ ) : (
+ {metadata.name}
+ )}
+
+ )}
+
+ {type === 'series' && currentEpisode && (
+
+ {currentEpisode.episodeString}
+ {currentEpisode.name}
+ {currentEpisode.overview && (
+ {currentEpisode.overview}
+ )}
+
+ )}
+
+
+ {/* Right Panel: Streams List */}
+
+ {Platform.OS === 'android' && AndroidBlurView ? (
+
+
+ {/* Always show filter container to prevent layout shift */}
+
+ {!streamsEmpty && (
+
+ )}
+
+
+ {/* Active Scrapers Status */}
+ {activeFetchingScrapers.length > 0 && (
+
+ Fetching from:
+
+ {activeFetchingScrapers.map((scraperName, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Stream content area - always show ScrollView to prevent flash */}
+
+ {/* Show autoplay loading overlay if waiting for autoplay */}
+ {isAutoplayWaiting && !autoplayTriggered && (
+
+
+
+ Starting best stream...
+
+
+ )}
+
+ {renderStreamContent()}
+
+
+
+ ) : (
+
+
+ {/* Always show filter container to prevent layout shift */}
+
+ {!streamsEmpty && (
+
+ )}
+
+
+ {/* Active Scrapers Status */}
+ {activeFetchingScrapers.length > 0 && (
+
+ Fetching from:
+
+ {activeFetchingScrapers.map((scraperName, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Stream content area - always show ScrollView to prevent flash */}
+
+ {/* Show autoplay loading overlay if waiting for autoplay */}
+ {isAutoplayWaiting && !autoplayTriggered && (
+
+
+
+ Starting best stream...
+
+
+ )}
+
+ {renderStreamContent()}
+
+
+
+ )}
+
+
+ );
+};
+
+// Create a function to generate styles with the current theme colors
+const createStyles = (colors: any) => StyleSheet.create({
+ 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,
+ },
+ streamsContent: {
+ flex: 1,
+ width: '100%',
+ zIndex: 2,
+ },
+ streamsContainer: {
+ paddingHorizontal: 12,
+ paddingBottom: 20,
+ 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,
+ },
+ noStreams: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 32,
+ },
+ noStreamsText: {
+ color: colors.textMuted,
+ fontSize: 16,
+ marginTop: 16,
+ },
+ noStreamsSubText: {
+ color: colors.mediumEmphasis,
+ fontSize: 14,
+ marginTop: 8,
+ textAlign: 'center',
+ },
+ addSourcesButton: {
+ marginTop: 24,
+ paddingHorizontal: 20,
+ paddingVertical: 10,
+ backgroundColor: colors.primary,
+ borderRadius: 8,
+ },
+ addSourcesButtonText: {
+ color: colors.white,
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ loadingContainer: {
+ alignItems: 'center',
+ paddingVertical: 24,
+ },
+ loadingText: {
+ color: colors.primary,
+ fontSize: 12,
+ marginLeft: 4,
+ fontWeight: '500',
+ },
+ footerLoading: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 16,
+ },
+ footerLoadingText: {
+ color: colors.primary,
+ fontSize: 12,
+ marginLeft: 8,
+ fontWeight: '500',
+ },
+ 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,
+ },
+ 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',
+ },
+ // 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',
+ },
+});
+
+export default memo(TabletStreamsLayout);
diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx
index 6b596ee2..2ffe5bcf 100644
--- a/src/screens/StreamsScreen.tsx
+++ b/src/screens/StreamsScreen.tsx
@@ -52,6 +52,13 @@ import { useDownloads } from '../contexts/DownloadsContext';
import { streamCacheService } from '../services/streamCacheService';
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;
@@ -96,350 +103,8 @@ const detectMkvViaHead = async (url: string, headers?: Record) =
};
// Animated Components
-const AnimatedImage = memo(({
- source,
- style,
- contentFit,
- onLoad
-}: {
- source: { uri: string } | undefined;
- style: any;
- contentFit: any;
- onLoad?: () => void;
-}) => {
- const opacity = useSharedValue(0);
-
- const animatedStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
- }));
-
- useEffect(() => {
- if (source?.uri) {
- opacity.value = withTiming(1, { duration: 300 });
- } else {
- opacity.value = 0;
- }
- }, [source?.uri]);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- opacity.value = 0;
- };
- }, []);
-
- return (
-
-
-
- );
-});
-
-const AnimatedText = memo(({
- children,
- style,
- delay = 0,
- numberOfLines
-}: {
- children: React.ReactNode;
- style: any;
- delay?: number;
- numberOfLines?: number;
-}) => {
- const opacity = useSharedValue(0);
- const translateY = useSharedValue(20);
-
- const animatedStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
- transform: [{ translateY: translateY.value }],
- }));
-
- useEffect(() => {
- opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
- translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
- }, [delay]);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- opacity.value = 0;
- translateY.value = 20;
- };
- }, []);
-
- return (
-
- {children}
-
- );
-});
-
-const AnimatedView = memo(({
- children,
- style,
- delay = 0
-}: {
- children: React.ReactNode;
- style?: any;
- delay?: number;
-}) => {
- const opacity = useSharedValue(0);
- const translateY = useSharedValue(20);
-
- const animatedStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
- transform: [{ translateY: translateY.value }],
- }));
-
- useEffect(() => {
- opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
- translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
- }, [delay]);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- opacity.value = 0;
- translateY.value = 20;
- };
- }, []);
-
- return (
-
- {children}
-
- );
-});
// Extracted Components
-const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos, scraperLogo, showAlert, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName, parentId, parentImdbId }: {
- 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;
- parentId?: string; // Content ID (e.g., tt0903747 or tmdb:1396)
- parentImdbId?: string; // IMDb ID if available
-}) => {
- const { useSettings } = require('../hooks/useSettings');
- const { settings } = useSettings();
- const { startDownload } = useDownloads();
- const { showSuccess, showInfo } = useToast();
-
- // 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') {
- showSuccess('URL Copied', 'Stream URL copied to clipboard!');
- } else {
- // iOS uses custom alert
- showAlert('Copied!', 'Stream URL has been copied to clipboard.');
- }
- } catch (error) {
- // Fallback: show URL in alert if clipboard fails
- if (Platform.OS === 'android') {
- showInfo('Stream URL', `Stream URL: ${stream.url}`);
- } else {
- showAlert('Stream URL', stream.url);
- }
- }
- }
- }, [stream.url, showAlert, showSuccess, showInfo]);
- 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;
- // Prevent duplicate downloads for the same exact URL
- try {
- const downloadsModule = require('../contexts/DownloadsContext');
- if (downloadsModule && downloadsModule.isDownloadingUrl && downloadsModule.isDownloadingUrl(url)) {
- showAlert('Already Downloading', 'This download has already started for this exact link.');
- return;
- }
- } catch {}
- // Show immediate feedback on both platforms
- showAlert('Starting Download', 'Download will be started.');
- 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';
-
- // Use parentId first (from route params), fallback to stream metadata
- const idForContent = parentId || parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
-
- // Extract tmdbId if available (from parentId or parent metadata)
- let tmdbId: number | undefined = undefined;
- if (parentId && parentId.startsWith('tmdb:')) {
- tmdbId = parseInt(parentId.split(':')[1], 10);
- } else if (typeof parent.tmdbId === 'number') {
- tmdbId = parent.tmdbId;
- }
-
- 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,
- // Pass metadata for progress tracking
- imdbId: parentImdbId || parent.imdbId || undefined,
- tmdbId: tmdbId,
- });
- showAlert('Download Started', 'Your download has been added to the queue.');
- } catch {}
- }, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]);
-
- const isDebrid = streamInfo.isDebrid;
- return (
-
- {/* Scraper Logo */}
- {showLogos && scraperLogo && (
-
-
-
- )}
-
-
-
-
-
- {streamInfo.displayName}
-
- {streamInfo.subTitle && (
-
- {streamInfo.subTitle}
-
- )}
-
-
- {/* Show loading indicator if stream is loading */}
- {isLoading && (
-
-
-
- {statusMessage || "Loading..."}
-
-
- )}
-
-
-
- {streamInfo.isDolby && (
-
- )}
-
- {streamInfo.size && (
-
- 💾 {streamInfo.size}
-
- )}
-
- {streamInfo.isDebrid && (
-
- DEBRID
-
- )}
-
-
-
-
- {settings?.enableDownloads !== false && (
-
-
-
- )}
-
- );
-});
const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => {
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
@@ -451,72 +116,7 @@ const QualityTag = React.memo(({ text, color, theme }: { text: string; color: st
);
});
-const PulsingChip = memo(({ text, delay }: { text: string; delay: number }) => {
- const { currentTheme } = useTheme();
- const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]);
- // Make chip static to avoid continuous animation load
- return (
-
- {text}
-
- );
-});
-const ProviderFilter = memo(({
- selectedProvider,
- providers,
- onSelect,
- theme
-}: {
- selectedProvider: string;
- providers: Array<{ id: string; name: string; }>;
- onSelect: (id: string) => void;
- theme: any;
-}) => {
- const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
-
- const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
- onSelect(item.id)}
- >
-
- {item.name}
-
-
- ), [selectedProvider, onSelect, styles]);
-
- return (
-
- item.id}
- horizontal
- showsHorizontalScrollIndicator={false}
- style={styles.filterScroll}
- bounces={true}
- overScrollMode="never"
- decelerationRate="fast"
- initialNumToRender={5}
- maxToRenderPerBatch={3}
- windowSize={3}
- removeClippedSubviews={true}
- getItemLayout={(data, index) => ({
- length: 100, // Approximate width of each item
- offset: 100 * index,
- index,
- })}
- />
-
- );
-});
export const StreamsScreen = () => {
const insets = useSafeAreaInsets();
@@ -2056,403 +1656,41 @@ export const StreamsScreen = () => {
)}
{isTablet ? (
- // TABLET LAYOUT - Full Screen Background
-
- {/* Full Screen Background */}
-
-
-
- {/* Left Panel: Movie Logo/Episode Info */}
-
- {type === 'movie' && metadata && (
-
- {metadata.logo && !movieLogoError ? (
- setMovieLogoError(true)}
- />
- ) : (
- {metadata.name}
- )}
-
- )}
-
- {type === 'series' && currentEpisode && (
-
- {currentEpisode.episodeString}
- {currentEpisode.name}
- {currentEpisode.overview && (
- {currentEpisode.overview}
- )}
-
- )}
-
-
- {/* Right Panel: Streams List */}
-
- {Platform.OS === 'android' && AndroidBlurView ? (
-
-
-
- {!streamsEmpty && (
-
- )}
-
-
- {/* Active Scrapers Status */}
- {activeFetchingScrapers.length > 0 && (
-
- Fetching from:
-
- {activeFetchingScrapers.map((scraperName, index) => (
-
- ))}
-
-
- )}
-
- {/* Update the streams/loading state display logic */}
- { showNoSourcesError ? (
-
-
- No streaming sources available
-
- Please add streaming sources in settings
-
- navigation.navigate('Addons')}
- >
- Add Sources
-
-
- ) : streamsEmpty ? (
- showInitialLoading ? (
-
-
-
- {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'}
-
-
- ) : showStillFetching ? (
-
-
- Still fetching streams…
-
- ) : (
- // No streams and not loading = no streams available
-
-
- No streams available
-
- )
- ) : (
- // Show streams immediately when available, even if still loading others
-
- {/* Show autoplay loading overlay if waiting for autoplay */}
- {isAutoplayWaiting && !autoplayTriggered && (
-
-
-
- Starting best stream...
-
-
- )}
-
-
- {sections.filter(Boolean).map((section, sectionIndex) => (
-
- {/* Section Header */}
- {renderSectionHeader({ section: section! })}
-
- {/* Stream Cards using FlatList */}
- {section!.data && section!.data.length > 0 ? (
- {
- if (item && item.url) {
- return `${item.url}-${sectionIndex}-${index}`;
- }
- return `empty-${sectionIndex}-${index}`;
- }}
- renderItem={({ item, index }) => (
-
- handleStreamPress(item)}
- index={index}
- isLoading={false}
- statusMessage={undefined}
- theme={currentTheme}
- showLogos={settings.showScraperLogos}
- scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null}
- showAlert={(t, m) => openAlert(t, m)}
- parentTitle={metadata?.name}
- parentType={type as 'movie' | 'series'}
- parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
- parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
- parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
- parentPosterUrl={episodeImage || metadata?.poster || undefined}
- providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
- parentId={id}
- parentImdbId={imdbId || undefined}
- />
-
- )}
- scrollEnabled={false}
- initialNumToRender={6}
- maxToRenderPerBatch={2}
- windowSize={3}
- removeClippedSubviews={true}
- showsVerticalScrollIndicator={false}
- getItemLayout={(data, index) => ({
- length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom)
- offset: 78 * index,
- index,
- })}
- />
- ) : null}
-
- ))}
-
- {/* Footer Loading */}
- {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
-
-
- Loading more sources...
-
- )}
-
-
- )}
-
-
- ) : (
-
-
-
- {!streamsEmpty && (
-
- )}
-
-
- {/* Active Scrapers Status */}
- {activeFetchingScrapers.length > 0 && (
-
- Fetching from:
-
- {activeFetchingScrapers.map((scraperName, index) => (
-
- ))}
-
-
- )}
-
- {/* Update the streams/loading state display logic */}
- { showNoSourcesError ? (
-
-
- No streaming sources available
-
- Please add streaming sources in settings
-
- navigation.navigate('Addons')}
- >
- Add Sources
-
-
- ) : streamsEmpty ? (
- showInitialLoading ? (
-
-
-
- {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'}
-
-
- ) : showStillFetching ? (
-
-
- Still fetching streams…
-
- ) : (
- // No streams and not loading = no streams available
-
-
- No streams available
-
- )
- ) : (
- // Show streams immediately when available, even if still loading others
-
- {/* Show autoplay loading overlay if waiting for autoplay */}
- {isAutoplayWaiting && !autoplayTriggered && (
-
-
-
- Starting best stream...
-
-
- )}
-
-
- {sections.filter(Boolean).map((section, sectionIndex) => (
-
- {/* Section Header */}
- {renderSectionHeader({ section: section! })}
-
- {/* Stream Cards using FlatList */}
- {section!.data && section!.data.length > 0 ? (
- {
- if (item && item.url) {
- return `${item.url}-${sectionIndex}-${index}`;
- }
- return `empty-${sectionIndex}-${index}`;
- }}
- renderItem={({ item, index }) => (
-
- handleStreamPress(item)}
- index={index}
- isLoading={false}
- statusMessage={undefined}
- theme={currentTheme}
- showLogos={settings.showScraperLogos}
- scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null}
- showAlert={(t, m) => openAlert(t, m)}
- parentTitle={metadata?.name}
- parentType={type as 'movie' | 'series'}
- parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
- parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
- parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
- parentPosterUrl={episodeImage || metadata?.poster || undefined}
- providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
- parentId={id}
- parentImdbId={imdbId || undefined}
- />
-
- )}
- scrollEnabled={false}
- initialNumToRender={6}
- maxToRenderPerBatch={2}
- windowSize={3}
- removeClippedSubviews={true}
- showsVerticalScrollIndicator={false}
- getItemLayout={(data, index) => ({
- length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom)
- offset: 78 * index,
- index,
- })}
- />
- ) : null}
-
- ))}
-
- {/* Footer Loading */}
- {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
-
-
- Loading more sources...
-
- )}
-
-
- )}
-
-
- )}
-
-
+
) : (
// PHONE LAYOUT (existing structure)
<>
@@ -3320,3 +2558,4 @@ const createStyles = (colors: any) => StyleSheet.create({
});
export default memo(StreamsScreen);
+