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 { AppState } from 'react-native';
import * as FileSystem from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { notificationService } from '../services/notificationService';
export type DownloadStatus = 'downloading' | 'completed' | 'paused' | 'error' | 'queued';
@ -46,8 +48,8 @@ type StartDownloadInput = {
type DownloadsContextValue = {
downloads: DownloadItem[];
startDownload: (input: StartDownloadInput) => Promise<void>;
pauseDownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<void>;
// pauseDownload: (id: string) => Promise<void>;
// resumeDownload: (id: string) => Promise<void>;
cancelDownload: (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(() => {
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(downloads)).catch(() => {});
}, [downloads]);
@ -197,6 +231,11 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
speedBps,
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
@ -213,6 +252,8 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
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 }));
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);
lastBytesRef.current.delete(compoundId);
} catch (e) {
@ -227,64 +268,64 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
}
}, [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 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 });
// 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,
}));
};
// 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]);
// 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);
@ -315,11 +356,11 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const value = useMemo<DownloadsContextValue>(() => ({
downloads,
startDownload,
pauseDownload,
resumeDownload,
// pauseDownload,
// resumeDownload,
cancelDownload,
removeDownload,
}), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]);
}), [downloads, startDownload, /*pauseDownload, resumeDownload,*/ cancelDownload, removeDownload]);
return (
<DownloadsContext.Provider value={value}>

View file

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