downloading feature initial commit

This commit is contained in:
tapframe 2025-09-29 16:02:53 +05:30
parent ebb7d4cec6
commit 271126b665
5 changed files with 1122 additions and 24 deletions

47
App.tsx
View file

@ -27,6 +27,7 @@ import { GenreProvider } from './src/contexts/GenreContext';
import { TraktProvider } from './src/contexts/TraktContext';
import { ThemeProvider, useTheme } from './src/contexts/ThemeContext';
import { TrailerProvider } from './src/contexts/TrailerContext';
import { DownloadsProvider } from './src/contexts/DownloadsContext';
import SplashScreen from './src/components/SplashScreen';
import UpdatePopup from './src/components/UpdatePopup';
import MajorUpdateOverlay from './src/components/MajorUpdateOverlay';
@ -156,29 +157,31 @@ const ThemedApp = () => {
theme={customNavigationTheme}
linking={undefined}
>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar style="light" />
{!isAppReady && <SplashScreen onFinish={handleSplashComplete} />}
{shouldShowApp && <AppNavigator initialRouteName={initialRouteName} />}
{Platform.OS === 'ios' && (
<UpdatePopup
visible={showUpdatePopup}
updateInfo={updateInfo}
onUpdateNow={handleUpdateNow}
onUpdateLater={handleUpdateLater}
onDismiss={handleDismiss}
isInstalling={isInstalling}
<DownloadsProvider>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar style="light" />
{!isAppReady && <SplashScreen onFinish={handleSplashComplete} />}
{shouldShowApp && <AppNavigator initialRouteName={initialRouteName} />}
{Platform.OS === 'ios' && (
<UpdatePopup
visible={showUpdatePopup}
updateInfo={updateInfo}
onUpdateNow={handleUpdateNow}
onUpdateLater={handleUpdateLater}
onDismiss={handleDismiss}
isInstalling={isInstalling}
/>
)}
<MajorUpdateOverlay
visible={githubUpdate.visible}
latestTag={githubUpdate.latestTag}
releaseNotes={githubUpdate.releaseNotes}
releaseUrl={githubUpdate.releaseUrl}
onDismiss={githubUpdate.onDismiss}
onLater={githubUpdate.onLater}
/>
)}
<MajorUpdateOverlay
visible={githubUpdate.visible}
latestTag={githubUpdate.latestTag}
releaseNotes={githubUpdate.releaseNotes}
releaseUrl={githubUpdate.releaseUrl}
onDismiss={githubUpdate.onDismiss}
onLater={githubUpdate.onLater}
/>
</View>
</View>
</DownloadsProvider>
</NavigationContainer>
</PaperProvider>
</AccountProvider>

View file

@ -0,0 +1,337 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import * as FileSystem from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';
export type DownloadStatus = 'downloading' | 'completed' | 'paused' | 'error' | 'queued';
export interface DownloadItem {
id: string; // unique id for this download (content id + episode if any)
contentId: string; // base id
type: 'movie' | 'series';
title: string; // movie title or show name
providerName?: string;
season?: number;
episode?: number;
episodeTitle?: string;
quality?: string;
size?: number; // total bytes if known
downloadedBytes: number;
totalBytes: number;
progress: number; // 0-100
status: DownloadStatus;
speedBps?: number;
etaSeconds?: number;
posterUrl?: string | null;
sourceUrl: string; // stream url
headers?: Record<string, string>;
fileUri?: string; // local file uri once downloading/finished
createdAt: number;
updatedAt: number;
}
type StartDownloadInput = {
id: string;
type: 'movie' | 'series';
title: string;
providerName?: string;
season?: number;
episode?: number;
episodeTitle?: string;
quality?: string;
posterUrl?: string | null;
url: string;
headers?: Record<string, string>;
};
type DownloadsContextValue = {
downloads: DownloadItem[];
startDownload: (input: StartDownloadInput) => Promise<void>;
pauseDownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<void>;
cancelDownload: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>;
};
const DownloadsContext = createContext<DownloadsContextValue | undefined>(undefined);
const STORAGE_KEY = 'downloads_state_v1';
function sanitizeFilename(name: string): string {
return name.replace(/[^a-z0-9\-_.()\s]/gi, '_').slice(0, 120).trim();
}
function getExtensionFromUrl(url: string): string {
const lower = url.toLowerCase();
if (/(\.|ext=)(m3u8)(\b|$)/i.test(lower)) return 'm3u8';
if (/(\.|ext=)(mp4)(\b|$)/i.test(lower)) return 'mp4';
if (/(\.|ext=)(mkv)(\b|$)/i.test(lower)) return 'mkv';
if (/(\.|ext=)(mpd)(\b|$)/i.test(lower)) return 'mpd';
return 'mp4';
}
export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
// Keep active resumables in memory (not persisted)
const resumablesRef = useRef<Map<string, FileSystem.DownloadResumable>>(new Map());
const lastBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map());
// Persist and restore
useEffect(() => {
(async () => {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const list = JSON.parse(raw) as Array<Partial<DownloadItem>>;
// Mark any in-progress as paused on restore (cannot resume across sessions reliably)
const restored: DownloadItem[] = list.map((d) => {
const status = (d.status as DownloadStatus) || 'paused';
const safe: DownloadItem = {
id: String(d.id),
contentId: String(d.contentId ?? d.id),
type: (d.type as 'movie' | 'series') ?? 'movie',
title: String(d.title ?? 'Content'),
providerName: d.providerName,
season: typeof d.season === 'number' ? d.season : undefined,
episode: typeof d.episode === 'number' ? d.episode : undefined,
episodeTitle: d.episodeTitle ? String(d.episodeTitle) : undefined,
quality: d.quality ? String(d.quality) : undefined,
size: typeof d.size === 'number' ? d.size : undefined,
downloadedBytes: typeof d.downloadedBytes === 'number' ? d.downloadedBytes : 0,
totalBytes: typeof d.totalBytes === 'number' ? d.totalBytes : 0,
progress: typeof d.progress === 'number' ? d.progress : 0,
status: status === 'downloading' || status === 'queued' ? 'paused' : status,
speedBps: undefined,
etaSeconds: undefined,
posterUrl: (d.posterUrl as any) ?? null,
sourceUrl: String(d.sourceUrl ?? ''),
headers: (d.headers as any) ?? undefined,
fileUri: d.fileUri ? String(d.fileUri) : undefined,
createdAt: typeof d.createdAt === 'number' ? d.createdAt : Date.now(),
updatedAt: typeof d.updatedAt === 'number' ? d.updatedAt : Date.now(),
};
return safe;
});
setDownloads(restored);
}
} catch {}
})();
}, []);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(downloads)).catch(() => {});
}, [downloads]);
const updateDownload = useCallback((id: string, updater: (d: DownloadItem) => DownloadItem) => {
setDownloads(prev => prev.map(d => (d.id === id ? updater(d) : d)));
}, []);
const startDownload = useCallback(async (input: StartDownloadInput) => {
const contentId = input.id;
// Compose per-episode id for series
const compoundId = input.type === 'series' && input.season && input.episode
? `${contentId}:S${input.season}E${input.episode}`
: contentId;
// If already exists and completed, do nothing
const existing = downloads.find(d => d.id === compoundId);
if (existing && (existing.status === 'completed' || existing.status === 'downloading' || existing.status === 'paused')) {
return;
}
// Create file path
const baseDir = FileSystem.documentDirectory || FileSystem.cacheDirectory || FileSystem.documentDirectory;
const ext = getExtensionFromUrl(input.url);
const filenameBase = input.type === 'series'
? `${sanitizeFilename(input.title)}.S${String(input.season || 0).padStart(2, '0')}E${String(input.episode || 0).padStart(2, '0')}`
: sanitizeFilename(input.title);
const fileUri = `${baseDir}downloads/${filenameBase}.${ext}`;
// Ensure directory exists
await FileSystem.makeDirectoryAsync(`${baseDir}downloads`, { intermediates: true }).catch(() => {});
const createdAt = Date.now();
const newItem: DownloadItem = {
id: compoundId,
contentId,
type: input.type,
title: input.title,
providerName: input.providerName,
season: input.season,
episode: input.episode,
episodeTitle: input.episodeTitle,
quality: input.quality,
size: undefined,
downloadedBytes: 0,
totalBytes: 0,
progress: 0,
status: 'downloading',
speedBps: 0,
etaSeconds: undefined,
posterUrl: input.posterUrl || null,
sourceUrl: input.url,
headers: input.headers,
fileUri,
createdAt,
updatedAt: createdAt,
};
setDownloads(prev => [newItem, ...prev]);
const progressCallback: FileSystem.DownloadProgressCallback = (data) => {
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
const now = Date.now();
const last = lastBytesRef.current.get(compoundId);
let speedBps = 0;
if (last) {
const deltaBytes = totalBytesWritten - last.bytes;
const deltaTime = Math.max(1, now - last.time) / 1000;
speedBps = deltaBytes / deltaTime;
}
lastBytesRef.current.set(compoundId, { bytes: totalBytesWritten, time: now });
updateDownload(compoundId, (d) => ({
...d,
downloadedBytes: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite || d.totalBytes,
progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : d.progress,
speedBps,
updatedAt: now,
}));
};
// Create resumable
const resumable = FileSystem.createDownloadResumable(
input.url,
fileUri,
{ headers: input.headers || {} },
progressCallback
);
resumablesRef.current.set(compoundId, resumable);
lastBytesRef.current.set(compoundId, { bytes: 0, time: Date.now() });
try {
const result = await resumable.downloadAsync();
if (!result) throw new Error('Download failed');
updateDownload(compoundId, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri }));
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
} catch (e) {
// If user paused, keep paused state, else error
const current = downloads.find(d => d.id === compoundId);
if (current && current.status === 'paused') {
return;
}
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
}
}, [downloads, updateDownload]);
const pauseDownload = useCallback(async (id: string) => {
const resumable = resumablesRef.current.get(id);
if (resumable) {
try {
await resumable.pauseAsync();
} catch {}
}
updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() }));
}, [updateDownload]);
const resumeDownload = useCallback(async (id: string) => {
const item = downloads.find(d => d.id === id);
if (!item) return;
const progressCallback: FileSystem.DownloadProgressCallback = (data) => {
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
const now = Date.now();
const last = lastBytesRef.current.get(id);
let speedBps = 0;
if (last) {
const deltaBytes = totalBytesWritten - last.bytes;
const deltaTime = Math.max(1, now - last.time) / 1000;
speedBps = deltaBytes / deltaTime;
}
lastBytesRef.current.set(id, { bytes: totalBytesWritten, time: now });
updateDownload(id, (d) => ({
...d,
downloadedBytes: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite || d.totalBytes,
progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : d.progress,
speedBps,
status: 'downloading',
updatedAt: now,
}));
};
let resumable = resumablesRef.current.get(id);
if (!resumable) {
resumable = FileSystem.createDownloadResumable(
item.sourceUrl,
item.fileUri || `${FileSystem.documentDirectory}downloads/${sanitizeFilename(item.title)}.mp4`,
{ headers: item.headers || {} },
progressCallback
);
resumablesRef.current.set(id, resumable);
}
try {
const result = await resumable.resumeAsync();
if (!result) throw new Error('Resume failed');
updateDownload(id, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri }));
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} catch (e) {
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
}
}, [downloads, updateDownload]);
const cancelDownload = useCallback(async (id: string) => {
const resumable = resumablesRef.current.get(id);
try {
if (resumable) {
try { await resumable.pauseAsync(); } catch {}
}
} finally {
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
}
const item = downloads.find(d => d.id === id);
if (item?.fileUri) {
await FileSystem.deleteAsync(item.fileUri, { idempotent: true }).catch(() => {});
}
setDownloads(prev => prev.filter(d => d.id !== id));
}, [downloads]);
const removeDownload = useCallback(async (id: string) => {
const item = downloads.find(d => d.id === id);
if (item?.fileUri && item.status === 'completed') {
await FileSystem.deleteAsync(item.fileUri, { idempotent: true }).catch(() => {});
}
setDownloads(prev => prev.filter(d => d.id !== id));
}, [downloads]);
const value = useMemo<DownloadsContextValue>(() => ({
downloads,
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
removeDownload,
}), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]);
return (
<DownloadsContext.Provider value={value}>
{children}
</DownloadsContext.Provider>
);
};
export function useDownloads(): DownloadsContextValue {
const ctx = useContext(DownloadsContext);
if (!ctx) throw new Error('useDownloads must be used within DownloadsProvider');
return ctx;
}

View file

@ -22,6 +22,7 @@ import { PostHogProvider } from 'posthog-react-native';
import HomeScreen from '../screens/HomeScreen';
import LibraryScreen from '../screens/LibraryScreen';
import SettingsScreen from '../screens/SettingsScreen';
import DownloadsScreen from '../screens/DownloadsScreen';
import MetadataScreen from '../screens/MetadataScreen';
import KSPlayerCore from '../components/player/KSPlayerCore';
import AndroidVideoPlayer from '../components/player/AndroidVideoPlayer';
@ -161,6 +162,7 @@ export type MainTabParamList = {
Home: undefined;
Library: undefined;
Search: undefined;
Downloads: undefined;
Settings: undefined;
};
@ -348,7 +350,7 @@ type IconNameType = 'home' | 'home-outline' | 'compass' | 'compass-outline' |
'play-box-multiple' | 'play-box-multiple-outline' |
'puzzle' | 'puzzle-outline' |
'cog' | 'cog-outline' | 'feature-search' | 'feature-search-outline' |
'magnify' | 'heart' | 'heart-outline';
'magnify' | 'heart' | 'heart-outline' | 'download' | 'download-outline';
// Add TabIcon component
const TabIcon = React.memo(({ focused, color, iconName }: {
@ -697,6 +699,9 @@ const MainTabs = () => {
case 'Search':
iconName = 'magnify';
break;
case 'Downloads':
iconName = 'download';
break;
case 'Settings':
iconName = 'cog';
break;
@ -824,6 +829,16 @@ const MainTabs = () => {
),
}}
/>
<Tab.Screen
name="Downloads"
component={DownloadsScreen}
options={{
tabBarLabel: 'Downloads',
tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'download' : 'download-outline'} size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}

View file

@ -0,0 +1,686 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
StatusBar,
Dimensions,
TouchableOpacity,
FlatList,
RefreshControl,
Alert,
Platform,
} 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 { useDownloads } from '../contexts/DownloadsContext';
import type { DownloadItem } from '../contexts/DownloadsContext';
const { height, width } = Dimensions.get('window');
// Download items come from DownloadsContext
// Empty state component
const EmptyDownloadsState: React.FC = () => {
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={() => {
// Navigate to search or home to find content
}}
>
<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;
}> = React.memo(({ item, onPress, onAction }) => {
const { currentTheme } = useTheme();
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;
case 'paused':
return currentTheme.colors.warning;
case 'error':
return currentTheme.colors.error;
case 'queued':
return currentTheme.colors.mediumEmphasis;
default:
return currentTheme.colors.mediumEmphasis;
}
};
const getStatusText = () => {
switch (item.status) {
case 'downloading':
{
const mbps = item.speedBps ? `${(item.speedBps / (1024 * 1024)).toFixed(2)} MB/s` : undefined;
const eta = item.etaSeconds ? `${Math.ceil(item.etaSeconds / 60)}m` : undefined;
return mbps ? `Downloading • ${mbps}${eta ? `${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.card }]}
onPress={() => onPress(item)}
activeOpacity={0.8}
>
{/* 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.progressInfo}>
<Text style={[styles.statusText, { color: currentTheme.colors.mediumEmphasis }]}>
{(item.providerName || 'Provider') + (item.quality ? ` ${item.quality}` : '')}
</Text>
</View>
<View style={styles.progressInfo}>
<Text style={[styles.statusText, { color: getStatusColor() }]}>
{getStatusText()}
</Text>
<Text style={[styles.progressText, { color: currentTheme.colors.mediumEmphasis }]}>
{formatBytes(item.downloadedBytes)} / {item.totalBytes ? formatBytes(item.totalBytes) : '—'}
</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={() => {
Alert.alert(
'Remove Download',
'Are you sure you want to remove this download?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => onAction(item, 'cancel') },
]
);
}}
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 { top: safeAreaTop } = useSafeAreaInsets();
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
// Animation values
const headerOpacity = useSharedValue(1);
// 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((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;
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,
forceVlc: Platform.OS === 'android' ? isMkv : false,
id: item.id,
type: item.type,
episodeId: undefined,
imdbId: undefined,
availableStreams: {},
backdrop: undefined,
videoType,
} as any);
}, [navigation]);
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 handleFilterPress = useCallback((filter: typeof selectedFilter) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setSelectedFilter(filter);
}, []);
// Focus effect
useFocusEffect(
useCallback(() => {
// In a real app, this would load downloads from the service
// For now, we'll just show empty state
}, [])
);
// Animated styles
const headerStyle = useAnimatedStyle(() => ({
opacity: headerOpacity.value,
}));
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.background
: currentTheme.colors.text,
}
]}>
{label}
</Text>
{count > 0 && (
<View style={[
styles.filterBadge,
{
backgroundColor: selectedFilter === filter
? currentTheme.colors.background
: currentTheme.colors.primary,
}
]}>
<Text style={[
styles.filterBadgeText,
{
color: selectedFilter === filter
? currentTheme.colors.primary
: currentTheme.colors.background,
}
]}>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.background }]}>
<StatusBar
translucent
barStyle="light-content"
backgroundColor="transparent"
/>
{/* Header */}
<Animated.View style={[
styles.header,
{
backgroundColor: currentTheme.colors.background,
paddingTop: safeAreaTop + 16,
},
headerStyle,
]}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Downloads
</Text>
{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>
)}
</Animated.View>
{/* Content */}
{downloads.length === 0 ? (
<EmptyDownloadsState />
) : (
<FlatList
data={filteredDownloads}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<DownloadItemComponent
item={item}
onPress={handleDownloadPress}
onAction={handleDownloadAction}
/>
)}
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>
)}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingHorizontal: 20,
paddingBottom: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
},
headerTitle: {
fontSize: 32,
fontWeight: '700',
marginBottom: 16,
},
filterContainer: {
flexDirection: 'row',
gap: 12,
},
filterButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
gap: 8,
},
filterButtonText: {
fontSize: 14,
fontWeight: '600',
},
filterBadge: {
minWidth: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 6,
},
filterBadgeText: {
fontSize: 12,
fontWeight: '700',
},
listContainer: {
padding: 20,
paddingTop: 8,
},
downloadItem: {
borderRadius: 16,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
downloadContent: {
flex: 1,
marginRight: 12,
},
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: 8,
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
statusText: {
fontSize: 14,
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: 40,
},
emptyIconContainer: {
width: 96,
height: 96,
borderRadius: 48,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
emptyTitle: {
fontSize: 24,
fontWeight: '700',
marginBottom: 8,
textAlign: 'center',
},
emptySubtitle: {
fontSize: 16,
textAlign: 'center',
lineHeight: 24,
marginBottom: 32,
},
exploreButton: {
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 24,
},
exploreButtonText: {
fontSize: 16,
fontWeight: '600',
},
emptyFilterContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyFilterTitle: {
fontSize: 18,
fontWeight: '600',
marginTop: 16,
marginBottom: 8,
},
emptyFilterSubtitle: {
fontSize: 14,
textAlign: 'center',
},
});
export default DownloadsScreen;

View file

@ -48,6 +48,7 @@ import { logger } from '../utils/logger';
import { isMkvStream } from '../utils/mkvDetection';
import CustomAlert from '../components/CustomAlert';
import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
import { useDownloads } from '../contexts/DownloadsContext';
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png';
@ -202,7 +203,7 @@ const AnimatedView = memo(({
});
// Extracted Components
const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos, scraperLogo, showAlert }: {
const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos, scraperLogo, showAlert, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName }: {
stream: Stream;
onPress: () => void;
index: number;
@ -212,7 +213,15 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
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;
}) => {
const { startDownload } = useDownloads();
// Handle long press to copy stream URL to clipboard
const handleLongPress = useCallback(async () => {
@ -284,6 +293,36 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
// Logo is provided by parent to avoid per-card async work
const handleDownload = useCallback(async () => {
try {
const url = stream.url;
if (!url) return;
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;
const provider = providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider';
const idForContent = parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
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,
});
toast('Download started', { duration: 1500, position: ToastPosition.BOTTOM });
} catch {}
}, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title]);
const isDebrid = streamInfo.isDebrid;
return (
<TouchableOpacity
@ -358,6 +397,17 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
color={theme.colors.white}
/>
</View>
<TouchableOpacity
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
onPress={handleDownload}
activeOpacity={0.7}
>
<MaterialIcons
name="download"
size={20}
color={theme.colors.highEmphasis}
/>
</TouchableOpacity>
</TouchableOpacity>
);
});
@ -1915,6 +1965,13 @@ export const StreamsScreen = () => {
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' ? currentEpisode?.season_number : undefined}
parentEpisode={type === 'series' ? currentEpisode?.episode_number : undefined}
parentEpisodeTitle={type === 'series' ? currentEpisode?.name : undefined}
parentPosterUrl={episodeImage || metadata?.poster || undefined}
providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
/>
</View>
)}