import React, { useCallback, useState, useEffect, useMemo, useRef } from 'react'; import { View, Text, StyleSheet, StatusBar, Dimensions, TouchableOpacity, FlatList, RefreshControl, Alert, Platform, Clipboard, Linking, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; import { useTheme } from '../contexts/ThemeContext'; import Animated, { useAnimatedStyle, useSharedValue, withTiming, withSpring, } from 'react-native-reanimated'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import FastImage from '@d11/react-native-fast-image'; import { useDownloads } from '../contexts/DownloadsContext'; import { useSettings } from '../hooks/useSettings'; import { VideoPlayerService } from '../services/videoPlayerService'; import type { DownloadItem } from '../contexts/DownloadsContext'; import { useToast } from '../contexts/ToastContext'; import CustomAlert from '../components/CustomAlert'; import ScreenHeader from '../components/common/ScreenHeader'; import { useScrollToTop } from '../contexts/ScrollToTopContext'; const { height, width } = Dimensions.get('window'); const isTablet = width >= 768; // Tablet-optimized poster sizes const HORIZONTAL_ITEM_WIDTH = isTablet ? width * 0.18 : width * 0.3; const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; const POSTER_WIDTH = isTablet ? 70 : 90; const POSTER_HEIGHT = isTablet ? 105 : 135; // Helper function to optimize poster URLs const optimizePosterUrl = (poster: string | undefined | null): string => { if (!poster || poster.includes('placeholder')) { return 'https://via.placeholder.com/80x120/333333/666666?text=No+Image'; } // For TMDB images, use larger sizes for bigger posters if (poster.includes('image.tmdb.org')) { return poster.replace(/\/w\d+\//, '/w300/'); } return poster; }; // Download items come from DownloadsContext // Empty state component const EmptyDownloadsState: React.FC<{ navigation: NavigationProp }> = ({ navigation }) => { const { currentTheme } = useTheme(); return ( No Downloads Yet Downloaded content will appear here for offline viewing { navigation.navigate('Search'); }} > Explore Content ); }; // Download item component const DownloadItemComponent: React.FC<{ item: DownloadItem; onPress: (item: DownloadItem) => void; onAction: (item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => void; onRequestRemove: (item: DownloadItem) => void; }> = React.memo(({ item, onPress, onAction, onRequestRemove }) => { const { currentTheme } = useTheme(); const { settings } = useSettings(); const { showSuccess, showInfo } = useToast(); const [posterUrl, setPosterUrl] = useState(item.posterUrl || null); const borderRadius = settings.posterBorderRadius ?? 12; // Try to fetch poster if not available useEffect(() => { if (!posterUrl && (item.imdbId || item.tmdbId)) { // This could be enhanced to fetch poster from TMDB API if needed // For now, we'll use the existing posterUrl or fallback to placeholder setPosterUrl(item.posterUrl || null); } }, [item.imdbId, item.tmdbId, item.posterUrl, posterUrl]); const handleLongPress = useCallback(() => { if (item.status === 'completed' && item.fileUri) { Clipboard.setString(item.fileUri); if (Platform.OS === 'android') { showSuccess('Path Copied', 'Local file path copied to clipboard'); } else { Alert.alert('Copied', 'Local file path copied to clipboard'); } } else if (item.status !== 'completed') { if (Platform.OS === 'android') { showInfo('Download Incomplete', 'Download is not complete yet'); } else { Alert.alert('Not Available', 'The local file path is available only after the download is complete.'); } } }, [item.status, item.fileUri, showSuccess, showInfo]); const formatBytes = (bytes?: number) => { if (!bytes || bytes <= 0) return '0 B'; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); const v = bytes / Math.pow(1024, i); return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${sizes[i]}`; }; const getStatusColor = () => { switch (item.status) { case 'downloading': return currentTheme.colors.primary; case 'completed': return currentTheme.colors.success || '#4CAF50'; case 'paused': return currentTheme.colors.warning || '#FF9500'; case 'error': return currentTheme.colors.error || '#FF3B30'; case 'queued': return currentTheme.colors.mediumEmphasis; default: return currentTheme.colors.mediumEmphasis; } }; const getStatusText = () => { switch (item.status) { case 'downloading': const eta = item.etaSeconds ? `${Math.ceil(item.etaSeconds / 60)}m` : undefined; return eta ? `Downloading • ${eta}` : 'Downloading'; case 'completed': return 'Completed'; case 'paused': return 'Paused'; case 'error': return 'Error'; case 'queued': return 'Queued'; default: return 'Unknown'; } }; const getActionIcon = () => { switch (item.status) { case 'downloading': return 'pause'; case 'paused': case 'error': return 'play'; case 'queued': return 'play'; default: return null; } }; const handleActionPress = () => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); switch (item.status) { case 'downloading': onAction(item, 'pause'); break; case 'paused': case 'error': case 'queued': onAction(item, 'resume'); break; } }; return ( onPress(item)} onLongPress={handleLongPress} activeOpacity={0.8} > {/* Poster */} {/* Status indicator overlay */} {/* Content info */} {item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2, '0')}E${String(item.episode).padStart(2, '0')}` : ''} {item.type === 'series' && ( S{item.season?.toString().padStart(2, '0')}E{item.episode?.toString().padStart(2, '0')} • {item.episodeTitle} )} {/* Progress section */} {/* Provider + quality row */} {item.providerName || 'Provider'} {/* Status row */} {getStatusText()} {/* Size row */} {formatBytes(item.downloadedBytes)} / {item.totalBytes ? formatBytes(item.totalBytes) : '—'} {/* Warning for small files */} {item.totalBytes && item.totalBytes < 1048576 && ( // Less than 1MB May not play - streaming playlist )} {/* Progress bar */} {item.progress || 0}% {item.etaSeconds && item.status === 'downloading' && ( {Math.ceil(item.etaSeconds / 60)}m remaining )} {/* Action buttons */} {getActionIcon() && ( )} onRequestRemove(item)} activeOpacity={0.7} > ); }); const DownloadsScreen: React.FC = () => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); const { settings } = useSettings(); const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); const { showSuccess, showInfo } = useToast(); const [isRefreshing, setIsRefreshing] = useState(false); const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all'); const [showHelpAlert, setShowHelpAlert] = useState(false); const [showRemoveAlert, setShowRemoveAlert] = useState(false); const [pendingRemoveItem, setPendingRemoveItem] = useState(null); const flatListRef = useRef(null); // Scroll to top handler const scrollToTop = useCallback(() => { flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); }, []); useScrollToTop('Downloads', scrollToTop); // Filter downloads based on selected filter const filteredDownloads = useMemo(() => { if (selectedFilter === 'all') return downloads; return downloads.filter(item => { switch (selectedFilter) { case 'downloading': return item.status === 'downloading' || item.status === 'queued'; case 'completed': return item.status === 'completed'; case 'paused': return item.status === 'paused' || item.status === 'error'; default: return true; } }); }, [downloads, selectedFilter]); // Statistics const stats = useMemo(() => { const total = downloads.length; const downloading = downloads.filter(item => item.status === 'downloading' || item.status === 'queued' ).length; const completed = downloads.filter(item => item.status === 'completed').length; const paused = downloads.filter(item => item.status === 'paused' || item.status === 'error' ).length; return { total, downloading, completed, paused }; }, [downloads]); // Handlers const handleRefresh = useCallback(async () => { setIsRefreshing(true); // In a real app, this would refresh the downloads from the service await new Promise(resolve => setTimeout(resolve, 1000)); setIsRefreshing(false); }, []); const handleDownloadPress = useCallback(async (item: DownloadItem) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); if (item.status !== 'completed') { Alert.alert('Download not ready', 'Please wait until the download completes.'); return; } const uri = (item as any).fileUri || (item as any).sourceUrl; if (!uri) return; // Infer videoType and mkv const lower = String(uri).toLowerCase(); const isMkv = /\.mkv(\?|$)/i.test(lower) || /(?:[?&]ext=|container=|format=)mkv\b/i.test(lower); const isM3u8 = /\.m3u8(\?|$)/i.test(lower); const isMpd = /\.mpd(\?|$)/i.test(lower); const isMp4 = /\.mp4(\?|$)/i.test(lower); const videoType = isM3u8 ? 'm3u8' : isMpd ? 'mpd' : isMp4 ? 'mp4' : undefined; // Use external player if enabled in settings if (settings.useExternalPlayerForDownloads) { if (Platform.OS === 'android') { try { // Use VideoPlayerService for Android external playback const success = await VideoPlayerService.playVideo(uri, { useExternalPlayer: true, title: item.title, episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, episodeNumber: item.type === 'series' && item.season && item.episode ? `S${item.season}E${item.episode}` : undefined, }); if (success) return; // Fall through to internal player if external fails } catch (error) { console.error('External player failed:', error); // Fall through to internal player } } else if (Platform.OS === 'ios') { const streamUrl = encodeURIComponent(uri); let externalPlayerUrls: string[] = []; switch (settings.preferredPlayer) { case 'vlc': externalPlayerUrls = [ `vlc://${uri}`, `vlc-x-callback://x-callback-url/stream?url=${streamUrl}`, `vlc://${streamUrl}` ]; break; case 'outplayer': externalPlayerUrls = [ `outplayer://${uri}`, `outplayer://${streamUrl}`, `outplayer://play?url=${streamUrl}`, `outplayer://stream?url=${streamUrl}`, `outplayer://play/browser?url=${streamUrl}` ]; break; case 'infuse': externalPlayerUrls = [ `infuse://x-callback-url/play?url=${streamUrl}`, `infuse://play?url=${streamUrl}`, `infuse://${streamUrl}` ]; break; case 'vidhub': externalPlayerUrls = [ `vidhub://play?url=${streamUrl}`, `vidhub://${streamUrl}` ]; break; case 'infuse_livecontainer': const infuseUrls = [ `infuse://x-callback-url/play?url=${streamUrl}`, `infuse://play?url=${streamUrl}`, `infuse://${streamUrl}` ]; externalPlayerUrls = infuseUrls.map(infuseUrl => { const encoded = Buffer.from(infuseUrl).toString('base64'); return `livecontainer://open-url?url=${encoded}`; }); break; default: // Internal logic will handle 'internal' choice break; } if (settings.preferredPlayer !== 'internal') { // Try each URL format in sequence const tryNextUrl = (index: number) => { if (index >= externalPlayerUrls.length) { // Fallback to internal player if all external attempts fail openInternalPlayer(); return; } const url = externalPlayerUrls[index]; Linking.openURL(url) .catch(() => tryNextUrl(index + 1)); }; if (externalPlayerUrls.length > 0) { tryNextUrl(0); return; } } } } const openInternalPlayer = () => { // Build episodeId for series progress tracking (format: contentId:season:episode) const episodeId = item.type === 'series' && item.season && item.episode ? `${item.contentId}:${item.season}:${item.episode}` : undefined; const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; navigation.navigate(playerRoute as any, { uri, title: item.title, episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, season: item.type === 'series' ? item.season : undefined, episode: item.type === 'series' ? item.episode : undefined, quality: item.quality, year: undefined, streamProvider: 'Downloads', streamName: item.providerName || 'Offline', headers: undefined, id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking type: item.type, episodeId: episodeId, // Pass episodeId for series progress tracking imdbId: (item as any).imdbId || item.contentId, // Use imdbId if available, fallback to contentId availableStreams: {}, backdrop: undefined, videoType, } as any); }; openInternalPlayer(); }, [navigation, settings]); const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => { if (action === 'pause') pauseDownload(item.id); if (action === 'resume') resumeDownload(item.id); if (action === 'cancel') cancelDownload(item.id); }, [pauseDownload, resumeDownload, cancelDownload]); const handleRequestRemove = useCallback((item: DownloadItem) => { setPendingRemoveItem(item); setShowRemoveAlert(true); }, []); const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed' | 'paused') => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setSelectedFilter(filter); }, []); const showDownloadHelp = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setShowHelpAlert(true); }, []); // Focus effect useFocusEffect( useCallback(() => { // In a real app, this would load downloads from the service // For now, we'll just show empty state }, []) ); const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => ( handleFilterPress(filter)} activeOpacity={0.8} > {label} {count > 0 && ( {count} )} ); return ( {/* ScreenHeader Component */} } isTablet={isTablet} > {downloads.length > 0 && ( {renderFilterButton('all', 'All', stats.total)} {renderFilterButton('downloading', 'Active', stats.downloading)} {renderFilterButton('completed', 'Done', stats.completed)} {renderFilterButton('paused', 'Paused', stats.paused)} )} {/* Content */} {downloads.length === 0 ? ( ) : ( item.id} renderItem={({ item }) => ( )} style={{ backgroundColor: currentTheme.colors.darkBackground }} contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false} refreshControl={ } ListEmptyComponent={() => ( No {selectedFilter} downloads Try selecting a different filter )} /> )} {/* Help Alert */} setShowHelpAlert(false)} /> {/* Remove Download Confirmation */} setShowRemoveAlert(false) }, { label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} }, ]} onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }} /> ); }; const styles = StyleSheet.create({ container: { flex: 1, }, helpButton: { padding: 8, marginLeft: 8, }, filterContainer: { flexDirection: 'row', gap: isTablet ? 16 : 12, }, filterButton: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: isTablet ? 20 : 16, paddingVertical: isTablet ? 10 : 8, borderRadius: 20, gap: 8, }, filterButtonText: { fontSize: isTablet ? 16 : 14, fontWeight: '600', }, filterBadge: { minWidth: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 6, }, filterBadgeText: { fontSize: 12, fontWeight: '700', }, listContainer: { paddingHorizontal: 0, paddingTop: 8, paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav }, downloadItem: { borderRadius: 16, padding: isTablet ? 20 : 16, marginBottom: isTablet ? 16 : 12, flexDirection: 'row', alignItems: 'center', minHeight: isTablet ? 165 : 152, // Accommodate tablet poster + padding shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 3, marginHorizontal: 0, borderTopLeftRadius: 0, borderTopRightRadius: 0, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, posterContainer: { width: POSTER_WIDTH, height: POSTER_HEIGHT, borderRadius: 12, marginRight: isTablet ? 20 : 16, position: 'relative', overflow: 'hidden', backgroundColor: '#333', // Consistent border styling matching ContentItem borderWidth: 1.5, borderColor: 'rgba(255,255,255,0.15)', // Consistent shadow/elevation elevation: Platform.OS === 'android' ? 1 : 0, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, }, poster: { width: '100%', height: '100%', borderRadius: 12, }, statusOverlay: { position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.3, shadowRadius: 2, elevation: 2, }, downloadContent: { flex: 1, }, downloadHeader: { marginBottom: 12, }, titleContainer: { flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8, }, downloadTitle: { fontSize: 16, fontWeight: '600', flex: 1, }, qualityBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, }, qualityText: { fontSize: 12, fontWeight: '700', }, episodeInfo: { fontSize: 14, fontWeight: '500', }, progressSection: { gap: 4, }, providerRow: { marginBottom: 2, }, providerText: { fontSize: 12, fontWeight: '500', }, statusRow: { marginBottom: 2, }, sizeRow: { marginBottom: 6, }, warningRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginBottom: 6, }, warningText: { fontSize: 11, fontWeight: '500', }, progressInfo: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8, }, statusText: { fontSize: 13, fontWeight: '600', }, progressText: { fontSize: 12, fontWeight: '500', }, progressContainer: { height: 4, borderRadius: 2, overflow: 'hidden', }, progressBar: { height: '100%', borderRadius: 2, }, progressDetails: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, progressPercentage: { fontSize: 14, fontWeight: '700', }, etaText: { fontSize: 12, fontWeight: '500', }, actionContainer: { flexDirection: 'row', gap: 8, }, actionButton: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', }, emptyContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: isTablet ? 64 : 40, paddingBottom: isTablet ? 120 : 100, }, emptyIconContainer: { width: 96, height: 96, borderRadius: 48, alignItems: 'center', justifyContent: 'center', marginBottom: 24, }, emptyTitle: { fontSize: isTablet ? 28 : 24, fontWeight: '700', marginBottom: 8, textAlign: 'center', }, emptySubtitle: { fontSize: isTablet ? 18 : 16, textAlign: 'center', lineHeight: isTablet ? 28 : 24, marginBottom: isTablet ? 40 : 32, }, exploreButton: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 24, }, exploreButtonText: { fontSize: 16, fontWeight: '600', }, emptyFilterContainer: { alignItems: 'center', justifyContent: 'center', paddingVertical: isTablet ? 80 : 60, }, emptyFilterTitle: { fontSize: 18, fontWeight: '600', marginTop: 16, marginBottom: 8, }, emptyFilterSubtitle: { fontSize: 14, textAlign: 'center', }, }); export default DownloadsScreen;