NuvioStreaming/src/contexts/DownloadsContext.tsx
2025-11-09 14:09:48 +05:30

741 lines
30 KiB
TypeScript

import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { AppState } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import { mmkvStorage } from '../services/mmkvStorage';
import { notificationService } from '../services/notificationService';
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 (e.g., tt0903747 for series, tt0499549 for movies)
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;
// Additional metadata for progress tracking
imdbId?: string; // IMDb ID for better tracking
tmdbId?: number; // TMDB ID if available
// CRITICAL: Resume data for proper pause/resume across sessions
resumeData?: string; // The string which allows the API to resume a paused download
}
type StartDownloadInput = {
id: string; // Base content ID (e.g., tt0903747)
type: 'movie' | 'series';
title: string;
providerName?: string;
season?: number;
episode?: number;
episodeTitle?: string;
quality?: string;
posterUrl?: string | null;
url: string;
headers?: Record<string, string>;
// Additional metadata for progress tracking
imdbId?: string;
tmdbId?: number;
};
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>;
isDownloadingUrl: (url: string) => boolean;
};
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();
}
async function getExtensionFromHeaders(url: string, headers?: Record<string, string>): Promise<string | null> {
try {
const response = await fetch(url, { method: 'HEAD', headers });
const contentType = response.headers.get('content-type');
if (contentType) {
// Map common content types to extensions
if (contentType.includes('video/mp4')) return 'mp4';
if (contentType.includes('video/x-matroska')) return 'mkv';
if (contentType.includes('video/avi')) return 'avi';
if (contentType.includes('video/quicktime')) return 'mov';
if (contentType.includes('video/webm')) return 'webm';
if (contentType.includes('video/x-flv')) return 'flv';
if (contentType.includes('video/x-ms-wmv')) return 'wmv';
if (contentType.includes('video/x-m4v')) return 'm4v';
}
} catch (error) {
console.warn('[DownloadsContext] Could not get content-type from HEAD request', error);
}
return null;
}
function isDownloadableUrl(url: string): boolean {
if (!url) return false;
const lower = url.toLowerCase();
// Check for streaming formats that should NOT be downloadable (only m3u8 and DASH)
const streamingFormats = [
'.m3u8', // HLS streaming
'.mpd', // DASH streaming
'm3u8', // HLS without extension
'mpd', // DASH without extension
];
// Check if URL contains streaming format indicators
const isStreamingFormat = streamingFormats.some(format =>
lower.includes(format) ||
lower.includes(`ext=${format}`) ||
lower.includes(`format=${format}`) ||
lower.includes(`container=${format}`)
);
// Return true if it's NOT a streaming format (m3u8 or DASH)
return !isStreamingFormat;
}
function hashString(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i++) {
const chr = input.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer
}
// Convert to unsigned and hex
return (hash >>> 0).toString(16);
}
export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const downloadsRef = useRef(downloads);
useEffect(() => {
downloadsRef.current = downloads;
}, [downloads]);
// Keep active resumables in memory (not persisted)
const resumablesRef = useRef<Map<string, any>>(new Map());
const lastBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map());
// Persist and restore
useEffect(() => {
(async () => {
try {
const raw = await mmkvStorage.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(),
// Restore metadata for progress tracking
imdbId: (d as any).imdbId ? String((d as any).imdbId) : undefined,
tmdbId: typeof (d as any).tmdbId === 'number' ? (d as any).tmdbId : undefined,
// CRITICAL: Restore resumeData for proper resume across sessions
resumeData: (d as any).resumeData ? String((d as any).resumeData) : undefined,
};
return safe;
});
setDownloads(restored);
}
} catch {}
})();
}, []);
// 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(() => {
mmkvStorage.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 resumeDownload = useCallback(async (id: string) => {
console.log(`[DownloadsContext] Resuming download: ${id}`);
const item = downloadsRef.current.find(d => d.id === id); // Use ref
if (!item) {
console.log(`[DownloadsContext] No item found for download: ${id}`);
return;
}
// Update status to downloading immediately
updateDownload(id, (d) => ({ ...d, status: 'downloading', updatedAt: Date.now() }));
// Always try to use existing resumable first - this is crucial for proper resume
let resumable = resumablesRef.current.get(id);
if (resumable) {
console.log(`[DownloadsContext] Using existing resumable for download: ${id}`);
// Existing resumable should already have the correct progress callback and file URI
// No need to recreate it
} else {
console.log(`[DownloadsContext] Creating new resumable for download: ${id}`);
// Use the exact same file URI that was used initially
const fileUri = item.fileUri;
if (!fileUri) {
console.error(`[DownloadsContext] No fileUri found for download: ${id}`);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
return;
}
const progressCallback = (data: any) => {
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,
}));
// Fire background progress notification (throttled)
const current = downloadsRef.current.find(x => x.id === id);
if (current) {
maybeNotifyProgress({ ...current, downloadedBytes: totalBytesWritten, totalBytes: totalBytesExpectedToWrite || current.totalBytes, progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : current.progress });
}
};
// CRITICAL FIX: Create resumable with resumeData (5th parameter) for proper resume
resumable = FileSystem.createDownloadResumable(
item.sourceUrl,
fileUri,
{ headers: item.headers || {} },
progressCallback,
item.resumeData // This is the critical parameter that was missing!
);
resumablesRef.current.set(id, resumable);
lastBytesRef.current.set(id, { bytes: item.downloadedBytes, time: Date.now() });
}
try {
console.log(`[DownloadsContext] Calling resumeAsync for download: ${id}`);
const result = await resumable.resumeAsync();
// Check if download was paused during resume
const currentItem = downloadsRef.current.find(d => d.id === id);
if (currentItem && currentItem.status === 'paused') {
console.log(`[DownloadsContext] Download was paused during resume, keeping paused state: ${id}`);
// Keep resumable for next resume attempt - DO NOT DELETE
return;
}
if (!result) throw new Error('Resume failed');
console.log(`[DownloadsContext] Resume successful for download: ${id}`);
// Validate the downloaded file
try {
const fileInfo = await FileSystem.getInfoAsync(result.uri);
if (!fileInfo.exists) {
throw new Error('Downloaded file does not exist');
}
if (fileInfo.size === 0) {
throw new Error('Downloaded file is empty (0 bytes)');
}
// CRITICAL FIX: Check if file size matches expected size (if known)
const currentItem = downloadsRef.current.find(d => d.id === id);
if (currentItem && currentItem.totalBytes > 0) {
const sizeDifference = Math.abs(fileInfo.size - currentItem.totalBytes);
const percentDifference = (sizeDifference / currentItem.totalBytes) * 100;
// Allow up to 1% difference to account for potential header/metadata variations
if (percentDifference > 1) {
throw new Error(
`File size mismatch: expected ${currentItem.totalBytes} bytes, got ${fileInfo.size} bytes (${percentDifference.toFixed(2)}% difference)`
);
}
}
console.log(`[DownloadsContext] File validation passed: ${result.uri} (${fileInfo.size} bytes)`);
} catch (validationError) {
console.error(`[DownloadsContext] File validation failed: ${validationError}`);
// Delete the corrupted file
try {
await FileSystem.deleteAsync(result.uri, { idempotent: true });
console.log(`[DownloadsContext] Deleted corrupted file: ${result.uri}`);
} catch (deleteError) {
console.error(`[DownloadsContext] Failed to delete corrupted file: ${deleteError}`);
}
throw new Error(`Downloaded file validation failed: ${validationError}`);
}
// Ensure we use the correct file URI from the result
const finalFileUri = result.uri;
updateDownload(id, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: finalFileUri, resumeData: undefined }));
const done = downloadsRef.current.find(x => x.id === id);
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: finalFileUri } as DownloadItem);
// Clean up only after successful completion
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} catch (e: any) {
console.log(`[DownloadsContext] Resume threw error for download: ${id}`, e);
// Check if the error was due to pause
const currentItem = downloadsRef.current.find(d => d.id === id);
if (currentItem && currentItem.status === 'paused') {
console.log(`[DownloadsContext] Error was due to pause, keeping paused state and resumable: ${id}`);
// Keep resumable for next resume attempt - DO NOT DELETE
return;
}
// Only mark as error and clean up if it's a real error (not pause-related)
console.log(`[DownloadsContext] Marking download as error: ${id}`);
// For validation errors, clear resumeData and allow fresh restart
if (e.message && e.message.includes('validation failed')) {
console.log(`[DownloadsContext] Validation error - clearing resume data for fresh start: ${id}`);
updateDownload(id, (d) => ({
...d,
status: 'error',
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
// Clean up resumable to force fresh download on retry
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} else if (e.message && (e.message.includes('size mismatch') || e.message.includes('empty'))) {
// File corruption detected - clear everything for fresh start
console.log(`[DownloadsContext] File corruption detected - clearing for fresh start: ${id}`);
updateDownload(id, (d) => ({
...d,
status: 'error',
downloadedBytes: 0,
progress: 0,
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} else {
// Network or other errors - keep resume data for retry
console.log(`[DownloadsContext] Network/other error - keeping resume data for retry: ${id}`);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
// Keep resumable for potential retry
}
}
}, [updateDownload, maybeNotifyProgress, notifyCompleted]);
const startDownload = useCallback(async (input: StartDownloadInput) => {
// Validate that the URL is downloadable (not m3u8 or DASH)
if (!isDownloadableUrl(input.url)) {
throw new Error('This stream format cannot be downloaded. M3U8 (HLS) and DASH streaming formats are not supported for download.');
}
const contentId = input.id;
// Create unique ID per URL - allows same episode/movie from different sources
const urlHash = hashString(input.url);
const baseId = input.type === 'series' && input.season && input.episode
? `${contentId}:S${input.season}E${input.episode}`
: contentId;
const compoundId = `${baseId}:${urlHash}`;
// Check if this exact URL is already being downloaded
const existing = downloadsRef.current.find(d => d.sourceUrl === input.url);
if (existing) {
if (existing.status === 'completed') {
return; // Already completed, do nothing
} else if (existing.status === 'downloading') {
return; // Already downloading, do nothing
} else if (existing.status === 'paused' || existing.status === 'error') {
// Resume the paused or errored download instead of starting new one
await resumeDownload(existing.id);
return;
}
}
// Create file path - use a simple unique identifier with extension from HEAD request
const baseDir = (FileSystem as any).documentDirectory || (FileSystem as any).cacheDirectory || '/tmp/';
const uniqueId = `${Date.now()}_${Math.random().toString(36).substring(7)}`;
const extension = await getExtensionFromHeaders(input.url, input.headers);
const fileUri = extension ? `${baseDir}downloads/${uniqueId}.${extension}` : `${baseDir}downloads/${uniqueId}`;
// 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,
// Store metadata for progress tracking
imdbId: input.imdbId,
tmdbId: input.tmdbId,
// Initialize resumeData as undefined
resumeData: undefined,
};
setDownloads(prev => [newItem, ...prev]);
const progressCallback = (data: any) => {
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,
}));
// Fire background progress notification (throttled)
const current = downloadsRef.current.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
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();
// Check if download was paused during download
const currentItem = downloadsRef.current.find(d => d.id === compoundId);
if (currentItem && currentItem.status === 'paused') {
console.log(`[DownloadsContext] Download was paused during initial download, keeping paused state: ${compoundId}`);
// CRITICAL FIX: Save resumeData when paused
try {
const savableState = resumable.savable();
updateDownload(compoundId, (d) => ({
...d,
resumeData: savableState.resumeData,
updatedAt: Date.now(),
}));
} catch (savableError) {
console.log(`[DownloadsContext] Could not get savable state after pause: ${compoundId}`, savableError);
}
// Don't delete resumable - keep it for resume
return;
}
if (!result) throw new Error('Download failed');
// Validate the downloaded file
try {
const fileInfo = await FileSystem.getInfoAsync(result.uri);
if (!fileInfo.exists) {
throw new Error('Downloaded file does not exist');
}
if (fileInfo.size === 0) {
throw new Error('Downloaded file is empty (0 bytes)');
}
// CRITICAL FIX: Check if file size matches expected size (if known)
const currentItem = downloadsRef.current.find(d => d.id === compoundId);
if (currentItem && currentItem.totalBytes > 0) {
const sizeDifference = Math.abs(fileInfo.size - currentItem.totalBytes);
const percentDifference = (sizeDifference / currentItem.totalBytes) * 100;
// Allow up to 1% difference to account for potential header/metadata variations
if (percentDifference > 1) {
throw new Error(
`File size mismatch: expected ${currentItem.totalBytes} bytes, got ${fileInfo.size} bytes (${percentDifference.toFixed(2)}% difference)`
);
}
}
console.log(`[DownloadsContext] File validation passed: ${result.uri} (${fileInfo.size} bytes)`);
} catch (validationError) {
console.error(`[DownloadsContext] File validation failed: ${validationError}`);
// Delete the corrupted file
try {
await FileSystem.deleteAsync(result.uri, { idempotent: true });
console.log(`[DownloadsContext] Deleted corrupted file: ${result.uri}`);
} catch (deleteError) {
console.error(`[DownloadsContext] Failed to delete corrupted file: ${deleteError}`);
}
throw new Error(`Downloaded file validation failed: ${validationError}`);
}
updateDownload(compoundId, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri, resumeData: undefined }));
const done = downloadsRef.current.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: any) {
// If user paused, keep paused state, else error
const current = downloadsRef.current.find(d => d.id === compoundId);
if (current && current.status === 'paused') {
console.log(`[DownloadsContext] Error was due to pause during initial download, keeping paused state and resumable: ${compoundId}`);
// CRITICAL FIX: Save resumeData when paused
try {
const savableState = resumable.savable();
updateDownload(compoundId, (d) => ({
...d,
resumeData: savableState.resumeData,
updatedAt: Date.now(),
}));
} catch (savableError) {
console.log(`[DownloadsContext] Could not get savable state after pause error: ${compoundId}`, savableError);
}
// Don't delete resumable - keep it for resume
return;
}
console.log(`[DownloadsContext] Marking initial download as error: ${compoundId}`);
// For validation errors, clear resumeData and allow fresh restart
if (e.message && e.message.includes('validation failed')) {
console.log(`[DownloadsContext] Validation error - clearing resume data for fresh start: ${compoundId}`);
updateDownload(compoundId, (d) => ({
...d,
status: 'error',
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
// Clean up resumable to force fresh download on retry
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
} else if (e.message && (e.message.includes('size mismatch') || e.message.includes('empty'))) {
// File corruption detected - clear everything for fresh start
console.log(`[DownloadsContext] File corruption detected - clearing for fresh start: ${compoundId}`);
updateDownload(compoundId, (d) => ({
...d,
status: 'error',
downloadedBytes: 0,
progress: 0,
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
} else {
// Network or other errors - keep resume data for retry
console.log(`[DownloadsContext] Network/other error - keeping resume data for retry: ${compoundId}`);
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
// Keep resumable for potential retry
}
}
}, [updateDownload, resumeDownload]);
const pauseDownload = useCallback(async (id: string) => {
console.log(`[DownloadsContext] Pausing download: ${id}`);
// First, update the status to 'paused' immediately
// This will cause any ongoing download/resume operations to check status and exit gracefully
updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() }));
const resumable = resumablesRef.current.get(id);
if (resumable) {
try {
// CRITICAL FIX: Get the pause state which contains resumeData
const pauseResult = await resumable.pauseAsync();
console.log(`[DownloadsContext] Successfully paused download: ${id}`);
// CRITICAL FIX: Save the resumeData from pauseAsync result or savable()
// The pauseAsync returns a DownloadPauseState object with resumeData
const savableState = resumable.savable();
// Update the download item with the critical resumeData for future resume
updateDownload(id, (d) => ({
...d,
status: 'paused',
resumeData: savableState.resumeData || pauseResult.resumeData, // Store resume data
updatedAt: Date.now(),
}));
console.log(`[DownloadsContext] Saved resume data for download: ${id}`);
// Keep the resumable in memory for resume - DO NOT DELETE
} catch (error) {
console.log(`[DownloadsContext] Pause async failed (this is normal if already paused): ${id}`, error);
// Keep resumable even if pause fails - we still want to be able to resume
// Try to get savable state even if pause failed
try {
const savableState = resumable.savable();
updateDownload(id, (d) => ({
...d,
status: 'paused',
resumeData: savableState.resumeData,
updatedAt: Date.now(),
}));
} catch (savableError) {
console.log(`[DownloadsContext] Could not get savable state: ${id}`, savableError);
}
}
} else {
console.log(`[DownloadsContext] No resumable found for download: ${id}, just marked as paused`);
}
}, [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 = downloadsRef.current.find(d => d.id === id);
if (item?.fileUri) {
await FileSystem.deleteAsync(item.fileUri, { idempotent: true }).catch(() => {});
}
setDownloads(prev => prev.filter(d => d.id !== id));
}, []);
const removeDownload = useCallback(async (id: string) => {
const item = downloadsRef.current.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));
}, []);
const value = useMemo<DownloadsContextValue>(() => ({
downloads,
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
removeDownload,
isDownloadingUrl: (url: string) => {
return downloadsRef.current.some(d => d.sourceUrl === url && (d.status === 'queued' || d.status === 'downloading' || d.status === 'paused'));
},
}), [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;
}