mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-15 14:01:33 +00:00
987 lines
No EOL
30 KiB
TypeScript
987 lines
No EOL
30 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 { 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();
|
|
|
|
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 }]}>
|
|
No Downloads Yet
|
|
</Text>
|
|
<Text style={[styles.emptySubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
|
Downloaded content will appear here for offline viewing
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
|
|
onPress={() => {
|
|
navigation.navigate('Search');
|
|
}}
|
|
>
|
|
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.background }]}>
|
|
Explore Content
|
|
</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 [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('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 (
|
|
<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 || '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' }]}>
|
|
May not play - streaming playlist
|
|
</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 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 { 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('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) => (
|
|
<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="Downloads"
|
|
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', 'All', stats.total)}
|
|
{renderFilterButton('downloading', 'Active', stats.downloading)}
|
|
{renderFilterButton('completed', 'Done', stats.completed)}
|
|
{renderFilterButton('paused', '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 }]}>
|
|
No {selectedFilter} downloads
|
|
</Text>
|
|
<Text style={[styles.emptyFilterSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
|
Try selecting a different filter
|
|
</Text>
|
|
</View>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Help Alert */}
|
|
<CustomAlert
|
|
visible={showHelpAlert}
|
|
title="Download Limitations"
|
|
message="• Files smaller than 1MB are typically M3U8 streaming playlists and cannot be downloaded for offline viewing. These only work with online streaming and contain links to video segments, not the actual video content."
|
|
onClose={() => setShowHelpAlert(false)}
|
|
/>
|
|
|
|
{/* Remove Download Confirmation */}
|
|
<CustomAlert
|
|
visible={showRemoveAlert}
|
|
title="Remove Download"
|
|
message={pendingRemoveItem ? `Remove \"${pendingRemoveItem.title}\"${pendingRemoveItem.type === 'series' && pendingRemoveItem.season && pendingRemoveItem.episode ? ` S${String(pendingRemoveItem.season).padStart(2, '0')}E${String(pendingRemoveItem.episode).padStart(2, '0')}` : ''}?` : 'Remove this download?'}
|
|
actions={[
|
|
{ label: 'Cancel', onPress: () => setShowRemoveAlert(false) },
|
|
{ label: '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; |