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); +