NuvioStreaming/src/screens/DownloadsScreen.tsx

994 lines
No EOL
31 KiB
TypeScript

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 { useTranslation } from 'react-i18next';
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<RootStackParamList> }> = ({ navigation }) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
return (
<View style={styles.emptyContainer}>
<View style={[styles.emptyIconContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialCommunityIcons
name="download-outline"
size={48}
color={currentTheme.colors.mediumEmphasis}
/>
</View>
<Text style={[styles.emptyTitle, { color: currentTheme.colors.text }]}>
{t('downloads.no_downloads')}
</Text>
<Text style={[styles.emptySubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
{t('downloads.no_downloads_desc')}
</Text>
<TouchableOpacity
style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => {
navigation.navigate('Search');
}}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.background }]}>
{t('downloads.explore')}
</Text>
</TouchableOpacity>
</View>
);
};
// 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 { t } = useTranslation();
const [posterUrl, setPosterUrl] = useState<string | null>(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(t('downloads.path_copied'), t('downloads.path_copied_desc'));
} else {
Alert.alert(t('downloads.copied'), t('downloads.path_copied_desc'));
}
} else if (item.status !== 'completed') {
if (Platform.OS === 'android') {
showInfo(t('downloads.incomplete'), t('downloads.incomplete_desc'));
} else {
Alert.alert(t('downloads.not_available'), t('downloads.not_available_desc'));
}
}
}, [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 ? `${t('downloads.status_downloading')}${eta}` : t('downloads.status_downloading');
case 'completed':
return t('downloads.status_completed');
case 'paused':
return t('downloads.status_paused');
case 'error':
return t('downloads.status_error');
case 'queued':
return t('downloads.status_queued');
default:
return t('downloads.status_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 (
<TouchableOpacity
style={[styles.downloadItem, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => onPress(item)}
onLongPress={handleLongPress}
activeOpacity={0.8}
>
{/* Poster */}
<View style={[styles.posterContainer, { borderRadius }]}>
<FastImage
source={{ uri: optimizePosterUrl(posterUrl) }}
style={[styles.poster, { borderRadius }]}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Status indicator overlay */}
<View style={[styles.statusOverlay, { backgroundColor: getStatusColor() }]}>
<MaterialCommunityIcons
name={
item.status === 'completed' ? 'check' :
item.status === 'downloading' ? 'download' :
item.status === 'paused' ? 'pause' :
item.status === 'error' ? 'alert-circle' :
'clock'
}
size={12}
color="white"
/>
</View>
</View>
{/* Content info */}
<View style={styles.downloadContent}>
<View style={styles.downloadHeader}>
<View style={styles.titleContainer}>
<Text style={[styles.downloadTitle, { color: currentTheme.colors.text }]} numberOfLines={1}>
{item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2, '0')}E${String(item.episode).padStart(2, '0')}` : ''}
</Text>
</View>
{item.type === 'series' && (
<Text style={[styles.episodeInfo, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={1}>
S{item.season?.toString().padStart(2, '0')}E{item.episode?.toString().padStart(2, '0')} {item.episodeTitle}
</Text>
)}
</View>
{/* Progress section */}
<View style={styles.progressSection}>
{/* Provider + quality row */}
<View style={styles.providerRow}>
<Text style={[styles.providerText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.providerName || t('downloads.provider')}
</Text>
</View>
{/* Status row */}
<View style={styles.statusRow}>
<Text style={[styles.statusText, { color: getStatusColor() }]}>
{getStatusText()}
</Text>
</View>
{/* Size row */}
<View style={styles.sizeRow}>
<Text style={[styles.progressText, { color: currentTheme.colors.mediumEmphasis }]}>
{formatBytes(item.downloadedBytes)} / {item.totalBytes ? formatBytes(item.totalBytes) : '—'}
</Text>
</View>
{/* Warning for small files */}
{item.totalBytes && item.totalBytes < 1048576 && ( // Less than 1MB
<View style={styles.warningRow}>
<MaterialCommunityIcons
name="alert-circle"
size={14}
color={currentTheme.colors.warning || '#FF9500'}
/>
<Text style={[styles.warningText, { color: currentTheme.colors.warning || '#FF9500' }]}>
{t('downloads.streaming_playlist_warning')}
</Text>
</View>
)}
{/* Progress bar */}
<View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Animated.View
style={[
styles.progressBar,
{
backgroundColor: getStatusColor(),
width: `${item.progress || 0}%`,
},
]}
/>
</View>
<View style={styles.progressDetails}>
<Text style={[styles.progressPercentage, { color: currentTheme.colors.text }]}>
{item.progress || 0}%
</Text>
{item.etaSeconds && item.status === 'downloading' && (
<Text style={[styles.etaText, { color: currentTheme.colors.mediumEmphasis }]}>
{Math.ceil(item.etaSeconds / 60)}m {t('downloads.remaining')}
</Text>
)}
</View>
</View>
</View>
{/* Action buttons */}
<View style={styles.actionContainer}>
{getActionIcon() && (
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={handleActionPress}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={getActionIcon() as any}
size={20}
color={currentTheme.colors.primary}
/>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => onRequestRemove(item)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="delete-outline"
size={20}
color={currentTheme.colors.error}
/>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
});
const DownloadsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { t } = useTranslation();
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<DownloadItem | null>(null);
const flatListRef = useRef<FlatList>(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(t('downloads.not_ready'), t('downloads.not_ready_desc'));
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) => (
<TouchableOpacity
key={filter}
style={[
styles.filterButton,
{
backgroundColor: selectedFilter === filter
? currentTheme.colors.primary
: currentTheme.colors.elevation1,
}
]}
onPress={() => handleFilterPress(filter)}
activeOpacity={0.8}
>
<Text style={[
styles.filterButtonText,
{
color: selectedFilter === filter
? currentTheme.colors.white
: currentTheme.colors.text,
}
]}>
{label}
</Text>
{count > 0 && (
<View style={[
styles.filterBadge,
{
backgroundColor: selectedFilter === filter
? currentTheme.colors.white
: currentTheme.colors.primary,
}
]}>
<Text style={[
styles.filterBadgeText,
{
color: selectedFilter === filter
? currentTheme.colors.primary
: currentTheme.colors.white,
}
]}>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
translucent
barStyle="light-content"
backgroundColor="transparent"
/>
{/* ScreenHeader Component */}
<ScreenHeader
title={t('downloads.title')}
rightActionComponent={
<TouchableOpacity
style={styles.helpButton}
onPress={showDownloadHelp}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="help-circle-outline"
size={24}
color={currentTheme.colors.mediumEmphasis}
/>
</TouchableOpacity>
}
isTablet={isTablet}
>
{downloads.length > 0 && (
<View style={styles.filterContainer}>
{renderFilterButton('all', t('downloads.filter_all'), stats.total)}
{renderFilterButton('downloading', t('downloads.filter_active'), stats.downloading)}
{renderFilterButton('completed', t('downloads.filter_done'), stats.completed)}
{renderFilterButton('paused', t('downloads.filter_paused'), stats.paused)}
</View>
)}
</ScreenHeader>
{/* Content */}
{downloads.length === 0 ? (
<EmptyDownloadsState navigation={navigation} />
) : (
<FlatList
ref={flatListRef}
data={filteredDownloads}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<DownloadItemComponent
item={item}
onPress={handleDownloadPress}
onAction={handleDownloadAction}
onRequestRemove={handleRequestRemove}
/>
)}
style={{ backgroundColor: currentTheme.colors.darkBackground }}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary]}
/>
}
ListEmptyComponent={() => (
<View style={styles.emptyFilterContainer}>
<MaterialCommunityIcons
name="filter-off"
size={48}
color={currentTheme.colors.mediumEmphasis}
/>
<Text style={[styles.emptyFilterTitle, { color: currentTheme.colors.text }]}>
{t('downloads.no_filter_results', { filter: selectedFilter })}
</Text>
<Text style={[styles.emptyFilterSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
{t('downloads.try_different_filter')}
</Text>
</View>
)}
/>
)}
{/* Help Alert */}
<CustomAlert
visible={showHelpAlert}
title={t('downloads.limitations_title')}
message={t('downloads.limitations_msg')}
onClose={() => setShowHelpAlert(false)}
/>
{/* Remove Download Confirmation */}
<CustomAlert
visible={showRemoveAlert}
title={t('downloads.remove_title')}
message={pendingRemoveItem ? t('downloads.remove_confirm', {
title: pendingRemoveItem.title,
season_episode: pendingRemoveItem.type === 'series' && pendingRemoveItem.season && pendingRemoveItem.episode ? ` S${String(pendingRemoveItem.season).padStart(2, '0')}E${String(pendingRemoveItem.episode).padStart(2, '0')}` : ''
}) : t('downloads.remove_confirm', { title: 'this download', season_episode: '' })}
actions={[
{ label: t('downloads.cancel'), onPress: () => setShowRemoveAlert(false) },
{ label: t('downloads.remove'), onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} },
]}
onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }}
/>
</View>
);
};
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;