diff --git a/App.tsx b/App.tsx index e59370abf..038fb007f 100644 --- a/App.tsx +++ b/App.tsx @@ -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} > - - - {!isAppReady && } - {shouldShowApp && } - {Platform.OS === 'ios' && ( - + + + {!isAppReady && } + {shouldShowApp && } + {Platform.OS === 'ios' && ( + + )} + - )} - - + + diff --git a/src/contexts/DownloadsContext.tsx b/src/contexts/DownloadsContext.tsx new file mode 100644 index 000000000..e5e1c7f55 --- /dev/null +++ b/src/contexts/DownloadsContext.tsx @@ -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; + 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; +}; + +type DownloadsContextValue = { + downloads: DownloadItem[]; + startDownload: (input: StartDownloadInput) => Promise; + pauseDownload: (id: string) => Promise; + resumeDownload: (id: string) => Promise; + cancelDownload: (id: string) => Promise; + removeDownload: (id: string) => Promise; +}; + +const DownloadsContext = createContext(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([]); + // Keep active resumables in memory (not persisted) + const resumablesRef = useRef>(new Map()); + const lastBytesRef = useRef>(new Map()); + + // Persist and restore + useEffect(() => { + (async () => { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (raw) { + const list = JSON.parse(raw) as Array>; + // 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(() => ({ + downloads, + startDownload, + pauseDownload, + resumeDownload, + cancelDownload, + removeDownload, + }), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]); + + return ( + + {children} + + ); +}; + +export function useDownloads(): DownloadsContextValue { + const ctx = useContext(DownloadsContext); + if (!ctx) throw new Error('useDownloads must be used within DownloadsProvider'); + return ctx; +} + + diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 40c668748..382311054 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -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 = () => { ), }} /> + ( + + ), + }} + /> { + const { currentTheme } = useTheme(); + + return ( + + + + + + No Downloads Yet + + + Downloaded content will appear here for offline viewing + + { + // Navigate to search or home to find content + }} + > + + Explore Content + + + + ); +}; + +// 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 ( + onPress(item)} + activeOpacity={0.8} + > + {/* 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') + (item.quality ? ` ${item.quality}` : '')} + + + + + {getStatusText()} + + + {formatBytes(item.downloadedBytes)} / {item.totalBytes ? formatBytes(item.totalBytes) : '—'} + + + + {/* Progress bar */} + + + + + + + {item.progress || 0}% + + {item.etaSeconds && item.status === 'downloading' && ( + + {Math.ceil(item.etaSeconds / 60)}m remaining + + )} + + + + + {/* Action buttons */} + + {getActionIcon() && ( + + + + )} + + { + 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} + > + + + + + ); +}); + +const DownloadsScreen: React.FC = () => { + const navigation = useNavigation>(); + 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) => ( + handleFilterPress(filter)} + activeOpacity={0.8} + > + + {label} + + {count > 0 && ( + + + {count} + + + )} + + ); + + return ( + + + + {/* Header */} + + + Downloads + + + {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 }) => ( + + )} + contentContainerStyle={styles.listContainer} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + ListEmptyComponent={() => ( + + + + No {selectedFilter} downloads + + + Try selecting a different filter + + + )} + /> + )} + + ); +}; + +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; \ No newline at end of file diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 1587fb12a..f9350afff 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -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 ( + + + ); }); @@ -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))} /> )}