mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
downloading feature initial commit
This commit is contained in:
parent
ebb7d4cec6
commit
271126b665
5 changed files with 1122 additions and 24 deletions
47
App.tsx
47
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}
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
337
src/contexts/DownloadsContext.tsx
Normal file
337
src/contexts/DownloadsContext.tsx
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
686
src/screens/DownloadsScreen.tsx
Normal file
686
src/screens/DownloadsScreen.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue