This commit is contained in:
tapframe 2025-09-29 16:53:10 +05:30
parent 271126b665
commit 92973c1c7b
4 changed files with 193 additions and 92 deletions

View file

@ -1,6 +1,8 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { AppState } from 'react-native';
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { notificationService } from '../services/notificationService';
export type DownloadStatus = 'downloading' | 'completed' | 'paused' | 'error' | 'queued'; export type DownloadStatus = 'downloading' | 'completed' | 'paused' | 'error' | 'queued';
@ -46,8 +48,8 @@ type StartDownloadInput = {
type DownloadsContextValue = { type DownloadsContextValue = {
downloads: DownloadItem[]; downloads: DownloadItem[];
startDownload: (input: StartDownloadInput) => Promise<void>; startDownload: (input: StartDownloadInput) => Promise<void>;
pauseDownload: (id: string) => Promise<void>; // pauseDownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<void>; // resumeDownload: (id: string) => Promise<void>;
cancelDownload: (id: string) => Promise<void>; cancelDownload: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>; removeDownload: (id: string) => Promise<void>;
}; };
@ -117,6 +119,38 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
})(); })();
}, []); }, []);
// Notifications are configured globally by notificationService
// Track app state to know foreground/background
const appStateRef = useRef<string>('active');
useEffect(() => {
const sub = AppState.addEventListener('change', (s) => {
appStateRef.current = s;
});
return () => sub.remove();
}, []);
// Cache last notified progress to reduce spam
const lastNotifyRef = useRef<Map<string, number>>(new Map());
const maybeNotifyProgress = useCallback(async (d: DownloadItem) => {
try {
if (appStateRef.current === 'active') return;
if (d.status !== 'downloading') return;
const prev = lastNotifyRef.current.get(d.id) ?? -1;
if (d.progress <= prev || d.progress - prev < 2) return; // notify every 2%
lastNotifyRef.current.set(d.id, d.progress);
await notificationService.notifyDownloadProgress(d.title, d.progress, d.downloadedBytes, d.totalBytes);
} catch {}
}, []);
const notifyCompleted = useCallback(async (d: DownloadItem) => {
try {
if (appStateRef.current === 'active') return;
await notificationService.notifyDownloadComplete(d.title);
} catch {}
}, []);
useEffect(() => { useEffect(() => {
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(downloads)).catch(() => {}); AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(downloads)).catch(() => {});
}, [downloads]); }, [downloads]);
@ -197,6 +231,11 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
speedBps, speedBps,
updatedAt: now, updatedAt: now,
})); }));
// Fire background progress notification (throttled)
const current = downloads.find(x => x.id === compoundId);
if (current) {
maybeNotifyProgress({ ...current, downloadedBytes: totalBytesWritten, totalBytes: totalBytesExpectedToWrite || current.totalBytes, progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : current.progress });
}
}; };
// Create resumable // Create resumable
@ -213,6 +252,8 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const result = await resumable.downloadAsync(); const result = await resumable.downloadAsync();
if (!result) throw new Error('Download failed'); if (!result) throw new Error('Download failed');
updateDownload(compoundId, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri })); updateDownload(compoundId, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri }));
const done = downloads.find(x => x.id === compoundId);
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: result.uri } as DownloadItem);
resumablesRef.current.delete(compoundId); resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId); lastBytesRef.current.delete(compoundId);
} catch (e) { } catch (e) {
@ -227,64 +268,64 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
} }
}, [downloads, updateDownload]); }, [downloads, updateDownload]);
const pauseDownload = useCallback(async (id: string) => { // const pauseDownload = useCallback(async (id: string) => {
const resumable = resumablesRef.current.get(id); // const resumable = resumablesRef.current.get(id);
if (resumable) { // if (resumable) {
try { // try {
await resumable.pauseAsync(); // await resumable.pauseAsync();
} catch {} // } catch {}
} // }
updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() })); // updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() }));
}, [updateDownload]); // }, [updateDownload]);
const resumeDownload = useCallback(async (id: string) => { // const resumeDownload = useCallback(async (id: string) => {
const item = downloads.find(d => d.id === id); // const item = downloads.find(d => d.id === id);
if (!item) return; // if (!item) return;
const progressCallback: FileSystem.DownloadProgressCallback = (data) => { // const progressCallback: FileSystem.DownloadProgressCallback = (data) => {
const { totalBytesWritten, totalBytesExpectedToWrite } = data; // const { totalBytesWritten, totalBytesExpectedToWrite } = data;
const now = Date.now(); // const now = Date.now();
const last = lastBytesRef.current.get(id); // const last = lastBytesRef.current.get(id);
let speedBps = 0; // let speedBps = 0;
if (last) { // if (last) {
const deltaBytes = totalBytesWritten - last.bytes; // const deltaBytes = totalBytesWritten - last.bytes;
const deltaTime = Math.max(1, now - last.time) / 1000; // const deltaTime = Math.max(1, now - last.time) / 1000;
speedBps = deltaBytes / deltaTime; // speedBps = deltaBytes / deltaTime;
} // }
lastBytesRef.current.set(id, { bytes: totalBytesWritten, time: now }); // lastBytesRef.current.set(id, { bytes: totalBytesWritten, time: now });
updateDownload(id, (d) => ({ // updateDownload(id, (d) => ({
...d, // ...d,
downloadedBytes: totalBytesWritten, // downloadedBytes: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite || d.totalBytes, // totalBytes: totalBytesExpectedToWrite || d.totalBytes,
progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : d.progress, // progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : d.progress,
speedBps, // speedBps,
status: 'downloading', // status: 'downloading',
updatedAt: now, // updatedAt: now,
})); // }));
}; // };
let resumable = resumablesRef.current.get(id); // let resumable = resumablesRef.current.get(id);
if (!resumable) { // if (!resumable) {
resumable = FileSystem.createDownloadResumable( // resumable = FileSystem.createDownloadResumable(
item.sourceUrl, // item.sourceUrl,
item.fileUri || `${FileSystem.documentDirectory}downloads/${sanitizeFilename(item.title)}.mp4`, // item.fileUri || `${FileSystem.documentDirectory}downloads/${sanitizeFilename(item.title)}.mp4`,
{ headers: item.headers || {} }, // { headers: item.headers || {} },
progressCallback // progressCallback
); // );
resumablesRef.current.set(id, resumable); // resumablesRef.current.set(id, resumable);
} // }
try { // try {
const result = await resumable.resumeAsync(); // const result = await resumable.resumeAsync();
if (!result) throw new Error('Resume failed'); // if (!result) throw new Error('Resume failed');
updateDownload(id, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri })); // updateDownload(id, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri }));
resumablesRef.current.delete(id); // resumablesRef.current.delete(id);
lastBytesRef.current.delete(id); // lastBytesRef.current.delete(id);
} catch (e) { // } catch (e) {
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() })); // updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
resumablesRef.current.delete(id); // resumablesRef.current.delete(id);
lastBytesRef.current.delete(id); // lastBytesRef.current.delete(id);
} // }
}, [downloads, updateDownload]); // }, [downloads, updateDownload]);
const cancelDownload = useCallback(async (id: string) => { const cancelDownload = useCallback(async (id: string) => {
const resumable = resumablesRef.current.get(id); const resumable = resumablesRef.current.get(id);
@ -315,11 +356,11 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const value = useMemo<DownloadsContextValue>(() => ({ const value = useMemo<DownloadsContextValue>(() => ({
downloads, downloads,
startDownload, startDownload,
pauseDownload, // pauseDownload,
resumeDownload, // resumeDownload,
cancelDownload, cancelDownload,
removeDownload, removeDownload,
}), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]); }), [downloads, startDownload, /*pauseDownload, resumeDownload,*/ cancelDownload, removeDownload]);
return ( return (
<DownloadsContext.Provider value={value}> <DownloadsContext.Provider value={value}>

View file

@ -100,11 +100,8 @@ const DownloadItemComponent: React.FC<{
const getStatusText = () => { const getStatusText = () => {
switch (item.status) { switch (item.status) {
case 'downloading': case 'downloading':
{ const eta = item.etaSeconds ? `${Math.ceil(item.etaSeconds / 60)}m` : undefined;
const mbps = item.speedBps ? `${(item.speedBps / (1024 * 1024)).toFixed(2)} MB/s` : undefined; return eta ? `Downloading • ${eta}` : 'Downloading';
const eta = item.etaSeconds ? `${Math.ceil(item.etaSeconds / 60)}m` : undefined;
return mbps ? `Downloading • ${mbps}${eta ? `${eta}` : ''}` : 'Downloading';
}
case 'completed': case 'completed':
return 'Completed'; return 'Completed';
case 'paused': case 'paused':
@ -121,12 +118,15 @@ const DownloadItemComponent: React.FC<{
const getActionIcon = () => { const getActionIcon = () => {
switch (item.status) { switch (item.status) {
case 'downloading': case 'downloading':
return 'pause'; // return 'pause'; // Resume support commented out
return null;
case 'paused': case 'paused':
case 'error': case 'error':
return 'play'; // return 'play'; // Resume support commented out
return null;
case 'queued': case 'queued':
return 'play'; // return 'play'; // Resume support commented out
return null;
default: default:
return null; return null;
} }
@ -134,15 +134,15 @@ const DownloadItemComponent: React.FC<{
const handleActionPress = () => { const handleActionPress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
switch (item.status) { switch (item.status) {
case 'downloading': case 'downloading':
onAction(item, 'pause'); // onAction(item, 'pause'); // Resume support commented out
break; break;
case 'paused': case 'paused':
case 'error': case 'error':
case 'queued': case 'queued':
onAction(item, 'resume'); // onAction(item, 'resume'); // Resume support commented out
break; break;
} }
}; };
@ -172,15 +172,20 @@ const DownloadItemComponent: React.FC<{
{/* Progress section */} {/* Progress section */}
<View style={styles.progressSection}> <View style={styles.progressSection}>
{/* Provider + quality row */} {/* Provider + quality row */}
<View style={styles.progressInfo}> <View style={styles.providerRow}>
<Text style={[styles.statusText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.providerText, { color: currentTheme.colors.mediumEmphasis }]}>
{(item.providerName || 'Provider') + (item.quality ? ` ${item.quality}` : '')} {(item.providerName || 'Provider') + (item.quality ? ` ${item.quality}` : '')}
</Text> </Text>
</View> </View>
<View style={styles.progressInfo}> {/* Status row */}
<View style={styles.statusRow}>
<Text style={[styles.statusText, { color: getStatusColor() }]}> <Text style={[styles.statusText, { color: getStatusColor() }]}>
{getStatusText()} {getStatusText()}
</Text> </Text>
</View>
{/* Size row */}
<View style={styles.sizeRow}>
<Text style={[styles.progressText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.progressText, { color: currentTheme.colors.mediumEmphasis }]}>
{formatBytes(item.downloadedBytes)} / {item.totalBytes ? formatBytes(item.totalBytes) : '—'} {formatBytes(item.downloadedBytes)} / {item.totalBytes ? formatBytes(item.totalBytes) : '—'}
</Text> </Text>
@ -257,10 +262,10 @@ const DownloadsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { top: safeAreaTop } = useSafeAreaInsets(); const { top: safeAreaTop } = useSafeAreaInsets();
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); const { downloads, /*pauseDownload, resumeDownload,*/ cancelDownload } = useDownloads();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all'); const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed'>('all');
// Animation values // Animation values
const headerOpacity = useSharedValue(1); const headerOpacity = useSharedValue(1);
@ -274,8 +279,8 @@ const DownloadsScreen: React.FC = () => {
return item.status === 'downloading' || item.status === 'queued'; return item.status === 'downloading' || item.status === 'queued';
case 'completed': case 'completed':
return item.status === 'completed'; return item.status === 'completed';
case 'paused': // case 'paused':
return item.status === 'paused' || item.status === 'error'; // return item.status === 'paused' || item.status === 'error'; // Resume support commented out
default: default:
return true; return true;
} }
@ -285,15 +290,15 @@ const DownloadsScreen: React.FC = () => {
// Statistics // Statistics
const stats = useMemo(() => { const stats = useMemo(() => {
const total = downloads.length; const total = downloads.length;
const downloading = downloads.filter(item => const downloading = downloads.filter(item =>
item.status === 'downloading' || item.status === 'queued' item.status === 'downloading' || item.status === 'queued'
).length; ).length;
const completed = downloads.filter(item => item.status === 'completed').length; const completed = downloads.filter(item => item.status === 'completed').length;
const paused = downloads.filter(item => // const paused = downloads.filter(item =>
item.status === 'paused' || item.status === 'error' // item.status === 'paused' || item.status === 'error'
).length; // ).length; // Resume support commented out
return { total, downloading, completed, paused }; return { total, downloading, completed /*, paused*/ };
}, [downloads]); }, [downloads]);
// Handlers // Handlers
@ -345,12 +350,12 @@ const DownloadsScreen: React.FC = () => {
}, [navigation]); }, [navigation]);
const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => { const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => {
if (action === 'pause') pauseDownload(item.id); // if (action === 'pause') pauseDownload(item.id);
if (action === 'resume') resumeDownload(item.id); // if (action === 'resume') resumeDownload(item.id);
if (action === 'cancel') cancelDownload(item.id); if (action === 'cancel') cancelDownload(item.id);
}, [pauseDownload, resumeDownload, cancelDownload]); }, [/*pauseDownload, resumeDownload,*/ cancelDownload]);
const handleFilterPress = useCallback((filter: typeof selectedFilter) => { const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed') => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setSelectedFilter(filter); setSelectedFilter(filter);
}, []); }, []);
@ -442,7 +447,7 @@ const DownloadsScreen: React.FC = () => {
{renderFilterButton('all', 'All', stats.total)} {renderFilterButton('all', 'All', stats.total)}
{renderFilterButton('downloading', 'Active', stats.downloading)} {renderFilterButton('downloading', 'Active', stats.downloading)}
{renderFilterButton('completed', 'Done', stats.completed)} {renderFilterButton('completed', 'Done', stats.completed)}
{renderFilterButton('paused', 'Paused', stats.paused)} {/* {renderFilterButton('paused', 'Paused', stats.paused)} */} {/* Resume support commented out */}
</View> </View>
)} )}
</Animated.View> </Animated.View>
@ -583,15 +588,30 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
}, },
progressSection: { progressSection: {
gap: 8, gap: 4,
},
providerRow: {
marginBottom: 2,
},
providerText: {
fontSize: 12,
fontWeight: '500',
},
statusRow: {
marginBottom: 2,
},
sizeRow: {
marginBottom: 6,
}, },
progressInfo: { progressInfo: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap',
gap: 8,
}, },
statusText: { statusText: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
progressText: { progressText: {

View file

@ -303,7 +303,8 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number); const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number);
const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number); const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number);
const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name; const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name;
const provider = providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider'; // Prefer the stream's display name (often includes provider + resolution)
const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider';
const idForContent = parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle; const idForContent = parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
await startDownload({ await startDownload({

View file

@ -320,6 +320,45 @@ class NotificationService {
} }
}; };
// Immediate notifications for download progress (background only)
public async notifyDownloadProgress(title: string, progress: number, downloadedBytes?: number, totalBytes?: number): Promise<void> {
try {
if (!this.settings.enabled) return;
if (AppState.currentState === 'active') return;
const downloadedMb = Math.floor((downloadedBytes || 0) / (1024 * 1024));
const totalMb = totalBytes ? Math.floor(totalBytes / (1024 * 1024)) : undefined;
const body = `${progress}%` + (totalMb !== undefined ? `${downloadedMb}MB / ${totalMb}MB` : '');
await Notifications.scheduleNotificationAsync({
content: {
title: `Downloading ${title}`,
body,
data: { kind: 'download-progress' },
},
trigger: null,
});
} catch (error) {
logger.error('[NotificationService] notifyDownloadProgress error:', error);
}
}
// Immediate notification for download completion (background only)
public async notifyDownloadComplete(title: string): Promise<void> {
try {
if (!this.settings.enabled) return;
if (AppState.currentState === 'active') return;
await Notifications.scheduleNotificationAsync({
content: {
title: 'Download complete',
body: title,
data: { kind: 'download-complete' },
},
trigger: null,
});
} catch (error) {
logger.error('[NotificationService] notifyDownloadComplete error:', error);
}
}
// Sync notifications for all library items using memory-efficient batching // Sync notifications for all library items using memory-efficient batching
private async syncNotificationsForLibrary(libraryItems: any[]): Promise<void> { private async syncNotificationsForLibrary(libraryItems: any[]): Promise<void> {
try { try {