diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 3c84056..ce6d557 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -13,7 +13,6 @@ import { Settings as SettingsIcon, } from '@/components/ui/icons'; import { useTranslate } from '@/lib/i18n'; -import { restoreUnfinishedDownloads } from '@/lib/stores/active-downloads-store'; import { useDownloadedStore } from '@/lib/stores/downloaded-store'; import { useTabConfigStore } from '@/lib/stores/tab-config-store'; @@ -25,10 +24,10 @@ export default function TabLayout(): React.ReactElement { const hydrateDownloads = useDownloadedStore((s) => s.hydrate); React.useEffect(() => { + // Hydrates the downloaded list AND restores unfinished download tasks + // (interrupted ones become retryable; their partial files are spared by + // the orphan cleanup inside). hydrateDownloads(); - // Bring back downloads that failed or were interrupted by an app restart, - // so they can be retried from the Downloads tab instead of being lost. - restoreUnfinishedDownloads(); }, [hydrateDownloads]); React.useEffect(() => { diff --git a/src/app/(app)/library.tsx b/src/app/(app)/library.tsx index 5dc357e..7f526a0 100644 --- a/src/app/(app)/library.tsx +++ b/src/app/(app)/library.tsx @@ -9,7 +9,7 @@ import { FocusAwareStatusBar } from '@/components/ui'; import { Trash } from '@/components/ui/icons'; import { VideoTile } from '@/components/video-tile'; import type { DownloadMetadata } from '@/lib/download'; -import { cancelDownload, retryDownload } from '@/lib/download/download-video'; +import { cancelDownload, pauseDownload, retryDownload } from '@/lib/download/download-video'; import { useColumns } from '@/lib/hooks/use-columns'; import { useTranslate } from '@/lib/i18n'; import { type ActiveDownload, useActiveDownloadsStore } from '@/lib/stores/active-downloads-store'; @@ -138,25 +138,35 @@ function DownloadRow({ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactElement { const t = useTranslate(); - const [cancelling, setCancelling] = React.useState(false); - const [retrying, setRetrying] = React.useState(false); + const [busy, setBusy] = React.useState(false); const indeterminate = task.progress < 0; const pct = indeterminate ? 0 : Math.round(task.progress * 100); const barWidth = indeterminate ? 40 : pct; const isFailed = task.status === 'error'; - // A retry is only meaningful while the task is still in its error state — - // once retryDownload flips it to preparing/downloading the button goes away. - const canRetry = isFailed && !retrying && Boolean(task.videoUrl || task.slug); + const isPaused = task.status === 'paused'; + // A retry is only meaningful while the task is still error/paused — once + // retryDownload flips it to preparing/downloading the button goes away. + const canRetry = (isFailed || isPaused) && !busy && Boolean(task.videoUrl || task.slug); const onCancel = async () => { - setCancelling(true); + setBusy(true); + // Cancelling a failed/paused task removes it together with its partial + // file (there is no transfer left to abort). await cancelDownload(task.baseId); }; const onRetry = async () => { - setRetrying(true); - await retryDownload(task.baseId); - setRetrying(false); + setBusy(true); + // false = still offline / source gone; the task stays errored so the + // user can delete it instead. + const restarted = await retryDownload(task.baseId); + if (!restarted) setBusy(false); + }; + + const onPause = async () => { + setBusy(true); + await pauseDownload(task.baseId); + setBusy(false); }; return ( @@ -176,17 +186,25 @@ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactEleme {task.title} - {task.status === 'error' + {isFailed ? `${t('library.status_failed', { error: task.error ? `: ${task.error}` : '' })}${t('library.tap_retry_hint')}` - : task.status === 'cancelled' - ? t('library.status_cancelled') - : indeterminate - ? t('library.status_downloading_indeterminate') - : t('library.status_downloading', { progress: pct })} + : isPaused + ? t('library.status_paused') + : task.status === 'cancelled' + ? t('library.status_cancelled') + : indeterminate + ? t('library.status_downloading_indeterminate') + : t('library.status_downloading', { progress: pct })} @@ -198,32 +216,42 @@ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactEleme className="items-center justify-center rounded-full bg-primary-500 px-3 py-1.5" testID="active-download-retry" > - - {retrying ? '…' : t('library.retry')} + {t('common.resume')} + + + + {t('common.delete')} + + + + ) : ( + + + + {t('common.pause')} - {cancelling ? '…' : t('common.cancel')} + {busy ? '…' : t('common.cancel')} - ) : ( - - - {cancelling ? '…' : t('common.cancel')} - - )} ); diff --git a/src/components/video-tile.test.tsx b/src/components/video-tile.test.tsx index 0582781..cd52222 100644 --- a/src/components/video-tile.test.tsx +++ b/src/components/video-tile.test.tsx @@ -1,4 +1,5 @@ import { endVideoOpen, resetOpenGuard } from '@/lib/navigation/open-guard'; +import { useActiveDownloadsStore } from '@/lib/stores/active-downloads-store'; import { useDownloadedStore } from '@/lib/stores/downloaded-store'; import { useFavoritesStore } from '@/lib/stores/favorites-store'; import { cleanup, fireEvent, render } from '@/lib/test-utils'; @@ -11,7 +12,7 @@ import { VideoTile } from './video-tile'; // jest's mock factory can reference it. const mockMenu = { lastProps: {} as { - actions?: { id: string; title: string }[]; + actions?: { id: string; title: string; attributes?: { disabled?: boolean } }[]; onPressAction?: (e: { nativeEvent: { event: string } }) => void; }, }; @@ -25,6 +26,14 @@ jest.mock('@react-native-menu/menu', () => { return { MenuView }; }); +const mockRetryDownload = jest.fn().mockResolvedValue(true); +jest.mock('@/lib/download/download-video', () => ({ + downloadVideo: jest.fn(), + cancelDownload: jest.fn(), + pauseDownload: jest.fn(), + retryDownload: (...args: unknown[]) => mockRetryDownload(...args), +})); + const mockPush = jest.fn(); jest.mock('expo-router', () => ({ useRouter: () => ({ push: mockPush }), @@ -61,6 +70,8 @@ beforeEach(() => { entries: [], loaded: false, }); + useActiveDownloadsStore.setState({ tasks: {} }); + mockRetryDownload.mockClear(); resetOpenGuard(); }); @@ -205,4 +216,80 @@ describe('VideoTile — context menu actions', () => { expect(useFavoritesStore.getState().favorites.some((f) => f.id === '1')).toBe(true); }); + + it('offers Resume download (enabled) when the task is errored or paused', () => { + useActiveDownloadsStore.setState({ + tasks: { + '1': { + baseId: '1', + title: 'My Video', + thumbnail: '', + progress: 0.4, + status: 'error', + startedAt: 0, + }, + }, + }); + render(); + const errored = mockMenu.lastProps.actions?.find((a) => a.id === 'download'); + expect(errored?.title).toBe('Resume download'); + expect(errored?.attributes?.disabled).toBeUndefined(); + + useActiveDownloadsStore.setState({ + tasks: { + '1': { + baseId: '1', + title: 'My Video', + thumbnail: '', + progress: 0.4, + status: 'paused', + startedAt: 0, + }, + }, + }); + render(); + const paused = mockMenu.lastProps.actions?.find((a) => a.id === 'download'); + expect(paused?.title).toBe('Resume download'); + expect(paused?.attributes?.disabled).toBeUndefined(); + }); + + it('disables Download while the task is actively downloading', () => { + useActiveDownloadsStore.setState({ + tasks: { + '1': { + baseId: '1', + title: 'My Video', + thumbnail: '', + progress: 0.4, + status: 'downloading', + startedAt: 0, + }, + }, + }); + render(); + + const downloadAction = mockMenu.lastProps.actions?.find((a) => a.id === 'download'); + expect(downloadAction?.title).toBe('Downloading…'); + expect(downloadAction?.attributes?.disabled).toBe(true); + }); + + it("resumes (retries) instead of starting fresh when a paused task's download is dispatched", () => { + useActiveDownloadsStore.setState({ + tasks: { + '1': { + baseId: '1', + title: 'My Video', + thumbnail: '', + progress: 0.4, + status: 'paused', + startedAt: 0, + }, + }, + }); + render(); + + mockMenu.lastProps.onPressAction?.({ nativeEvent: { event: 'download' } }); + + expect(mockRetryDownload).toHaveBeenCalledWith('1'); + }); }); diff --git a/src/components/video-tile.tsx b/src/components/video-tile.tsx index 14fb50b..f940340 100644 --- a/src/components/video-tile.tsx +++ b/src/components/video-tile.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Platform } from 'react-native'; import { Image, Pressable, Text, View } from '@/components/ui'; +import { retryDownload } from '@/lib/download/download-video'; import { useVideoActions } from '@/lib/hooks/use-video-actions'; import { tryBeginVideoOpen } from '@/lib/navigation/open-guard'; import { useActiveDownload } from '@/lib/stores/active-downloads-store'; @@ -62,7 +63,13 @@ function VideoTileImpl({ item, children }: Props): React.ReactElement { break; } case 'download': { - void toggleDownload(); + // An errored/paused task continues (resuming from the bytes already + // on disk when the previous attempt stopped mid-transfer). + if (active?.status === 'error' || active?.status === 'paused') { + void retryDownload(item.id); + } else { + void toggleDownload(); + } break; } case 'follow': { @@ -80,9 +87,19 @@ function VideoTileImpl({ item, children }: Props): React.ReactElement { }, { id: 'download', - title: isActive ? 'Downloading…' : isDownloaded ? 'Downloaded' : 'Download', + title: + active?.status === 'error' || active?.status === 'paused' + ? 'Resume download' + : isActive + ? 'Downloading…' + : isDownloaded + ? 'Downloaded' + : 'Download', state: (isDownloaded ? 'on' : 'off') as 'on' | 'off', - attributes: isActive || isDownloaded ? { disabled: true } : undefined, + attributes: + isActive && active?.status !== 'error' && active?.status !== 'paused' + ? { disabled: true } + : undefined, }, { id: 'follow', title: 'Follow author' }, ]; diff --git a/src/lib/download/download-video.test.ts b/src/lib/download/download-video.test.ts index 4416afb..c5686ef 100644 --- a/src/lib/download/download-video.test.ts +++ b/src/lib/download/download-video.test.ts @@ -6,36 +6,57 @@ const mockFS = { rejectDownload: null as ((e: unknown) => void) | null, downloadAsyncResult: { uri: 'file:///doc/videos/1_720p.mp4' } as { uri: string } | undefined, shouldThrow: false, + lastResumeOffset: null as string | undefined | null, }; -jest.mock('expo-file-system/legacy', () => ({ - documentDirectory: 'file:///doc/', - getInfoAsync: jest.fn().mockResolvedValue({ exists: true, size: 100 }), - makeDirectoryAsync: jest.fn(), - deleteAsync: jest.fn(), - createDownloadResumable: jest.fn((_url, _uri, _opts, cb) => { - mockFS.progressCallback = cb; - return { - downloadAsync: jest.fn( - () => - new Promise<{ uri: string } | undefined>((resolve, reject) => { - mockFS.rejectDownload = reject; - if (mockFS.shouldThrow) { - reject(new Error('network down')); - return; - } - if (mockFS.downloadAsyncResult) { - resolve(mockFS.downloadAsyncResult); - } - // else: stays pending until cancelDownload rejects it - }) - ), - cancelAsync: jest.fn(() => { - mockFS.rejectDownload?.(new Error('cancelled')); - }), - }; - }), -})); +jest.mock('expo-file-system/legacy', () => { + const pendingDownload = () => + new Promise<{ uri: string } | undefined>((resolve, reject) => { + mockFS.rejectDownload = reject; + if (mockFS.shouldThrow) { + reject(new Error('network down')); + return; + } + if (mockFS.downloadAsyncResult) { + resolve(mockFS.downloadAsyncResult); + } + // else: stays pending until cancelDownload rejects it + }); + return { + documentDirectory: 'file:///doc/', + getInfoAsync: jest.fn().mockResolvedValue({ exists: true, size: 100 }), + makeDirectoryAsync: jest.fn(), + deleteAsync: jest.fn(), + createDownloadResumable: jest.fn((_url, _uri, _opts, cb) => { + mockFS.progressCallback = cb; + return { + downloadAsync: jest.fn(() => pendingDownload()), + // Pausing does NOT settle downloadAsync — the transfer is parked and + // the promise only settles once the task completes or is cancelled. + pauseAsync: jest.fn().mockResolvedValue(undefined), + cancelAsync: jest.fn(() => { + mockFS.rejectDownload?.(new Error('cancelled')); + }), + }; + }), + // Rebuilt by retryDownload after an app restart to resume from the bytes + // already on disk (the 5th arg is the resume offset). + DownloadResumable: class { + readonly resumeAsync: jest.Mock; + constructor( + _url: string, + _uri: string, + _opts: unknown, + cb: (d: { totalBytesWritten: number; totalBytesExpectedToWrite: number }) => void, + resumeOffset?: string + ) { + mockFS.progressCallback = cb; + mockFS.lastResumeOffset = resumeOffset; + this.resumeAsync = jest.fn(() => pendingDownload()); + } + }, + }; +}); jest.mock('@/lib/download', () => ({ saveDownloadMetadata: jest.fn().mockResolvedValue(undefined), @@ -63,7 +84,12 @@ jest.mock('@/lib/storage', () => { }; }); -import { cancelDownload, downloadVideo, retryDownload } from '@/lib/download/download-video'; +import { + cancelDownload, + downloadVideo, + pauseDownload, + retryDownload, +} from '@/lib/download/download-video'; import { restoreUnfinishedDownloads, useActiveDownloadsStore, @@ -144,7 +170,7 @@ describe('retryDownload', () => { useActiveDownloadsStore.getState().fail('1', 'network down'); }; - it('retries a failed task from its stored source and completes', async () => { + it('resumes a failed task from its stored source and completes', async () => { failedTaskWithSource(); await retryDownload('1'); @@ -154,14 +180,41 @@ describe('retryDownload', () => { expect(useDownloadedStore.getState().entries.some((e) => e.videoId === '1_720p')).toBe(true); }); - it('deletes the leftover partial file before restarting', async () => { + it('resumes from the partial file on disk instead of restarting from zero', async () => { failedTaskWithSource(); - const { getInfoAsync, deleteAsync } = jest.requireMock('expo-file-system/legacy'); + const { getInfoAsync } = jest.requireMock('expo-file-system/legacy'); + // The mocked getInfoAsync reports a 100-byte partial file for the task. - await retryDownload('1'); + const restarted = await retryDownload('1'); - expect(deleteAsync).toHaveBeenCalledWith('file:///doc/videos/1_720p.mp4'); + // A resumable was rebuilt with the on-disk size as the resume offset. expect(getInfoAsync).toHaveBeenCalledWith('file:///doc/videos/1_720p.mp4'); + expect(mockFS.lastResumeOffset).toBe('100'); + expect(restarted).toBe(true); + }); + + it('pauses an in-flight download and keeps the task paused (not failed)', async () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'My Video', thumbnail: '' }); + mockFS.downloadAsyncResult = undefined; // keep the transfer in flight + void downloadVideo(opts); // settles only after resume+completion or cancel + await new Promise((r) => setTimeout(r, 50)); + useActiveDownloadsStore.getState().setProgress('1', 0.5, 50, 100); + + await pauseDownload('1'); + + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.status).toBe('paused'); + // The pause kept the progress so the row/bar resume where they stopped. + expect(task?.progress).toBe(0.5); + }); + + it('pauseDownload is a no-op for tasks that are not downloading', async () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'My Video', thumbnail: '' }); + useActiveDownloadsStore.getState().fail('1', 'network down'); + + await expect(pauseDownload('1')).resolves.toBeUndefined(); + + expect(useActiveDownloadsStore.getState().tasks['1']?.status).toBe('error'); }); it('is a no-op unless the task is in the error state', async () => { diff --git a/src/lib/download/download-video.ts b/src/lib/download/download-video.ts index 7398ac9..caa925a 100644 --- a/src/lib/download/download-video.ts +++ b/src/lib/download/download-video.ts @@ -24,10 +24,15 @@ export type DownloadVideoOptions = { onProgress?: (ratio: number) => void; }; -/** Active resumables so an in-flight download can be cancelled. Keyed by baseId. */ +/** + * Live resumables, keyed by baseId. Kept on FAILURE too — that is what makes + * a retry resumable: `resumeAsync()` continues from the bytes already on + * disk. Entries are dropped on success, cancel and app restart (resume state + * is in-memory only; after a restart the partial file's size is the offset). + */ const activeResumables = new Map< string, - { resumable: FileSystem.DownloadResumable; fileUri: string } + { resumable: FileSystem.DownloadResumable; fileUri: string; opts: DownloadVideoOptions } >(); /** Per-baseId throttle state for progress updates (avoids a render storm). */ @@ -44,26 +49,62 @@ function reportProgress(baseId: string, written: number, expected: number) { .setProgress(baseId, written / Math.max(1, expected), written, expected); } +/** Shared success path for downloadAsync() and resumeAsync() results. */ +async function finalizeDownload( + opts: DownloadVideoOptions, + result: FileSystem.FileSystemDownloadResult | undefined +): Promise { + const { videoId, title, thumbnail, slug, uploader, uploaderMemberId } = opts; + const baseId = baseIdOf(videoId); + if (!result?.uri) { + throw new Error('Download failed: no file produced'); + } + // If the user cancelled during the final stretch, don't register; drop the file. + if (useActiveDownloadsStore.getState().tasks[baseId]?.status === 'cancelled') { + try { + await FileSystem.deleteAsync(result.uri); + } catch { + // ignore + } + throw new Error('Download cancelled'); + } + + const fileInfo = await FileSystem.getInfoAsync(result.uri); + const size = (fileInfo as FileSystem.FileInfo & { size: number }).size || 0; + const quality = videoId.split('_').pop() || 'unknown'; + + const metadata: DownloadMetadata = { + videoId, + title, + thumbnail, + slug, + uploader, + uploaderMemberId, + uri: result.uri, + size, + quality, + downloadedAt: Date.now(), + }; + + await saveDownloadMetadata(metadata); + // Keep the reactive store in sync so badges light up across the app. + useDownloadedStore.getState().register(metadata); + useActiveDownloadsStore.getState().complete(baseId); + return metadata; +} + /** * Downloads a single video to disk, persists its metadata, and keeps both the * reactive completed store and the active-downloads store in sync. Shared by the * post-page download button and the long-press context menu. * * Callers should add the task to the active-downloads store (start) BEFORE - * calling this; downloadVideo then drives progress / complete / fail. + * calling this; downloadVideo then drives progress / complete / fail. This is + * the from-scratch path — resume goes through retryDownload, which reuses the + * resumable kept from the failed/paused attempt (or the partial file itself). */ export async function downloadVideo(opts: DownloadVideoOptions): Promise { - const { - videoUrl, - videoId, - title, - thumbnail, - downloadPath, - slug, - uploader, - uploaderMemberId, - onProgress, - } = opts; + const { videoUrl, videoId, downloadPath, onProgress } = opts; const baseId = baseIdOf(videoId); const videosDir = `${FileSystem.documentDirectory}${downloadPath}/`; @@ -73,6 +114,15 @@ export async function downloadVideo(opts: DownloadVideoOptions): Promise { const written = dl.totalBytesWritten; @@ -80,7 +130,7 @@ export async function downloadVideo(opts: DownloadVideoOptions): Promise 0 ? written / expected : 0); reportProgress(baseId, written, expected); }); - activeResumables.set(baseId, { resumable: downloadResumable, fileUri }); + activeResumables.set(baseId, { resumable: downloadResumable, fileUri, opts }); try { useActiveDownloadsStore.getState().setStatus(baseId, 'downloading'); @@ -92,99 +142,159 @@ export async function downloadVideo(opts: DownloadVideoOptions): Promise { +export async function pauseDownload(baseId: string): Promise { const entry = activeResumables.get(baseId); - // Signal cancellation first so the in-flight downloadVideo won't mark `fail`. - useActiveDownloadsStore.getState().setStatus(baseId, 'cancelled'); - - if (!entry) { - useActiveDownloadsStore.getState().remove(baseId); - return; - } + const task = useActiveDownloadsStore.getState().tasks[baseId]; + if (!entry || !task || task.status !== 'downloading') return; - activeResumables.delete(baseId); - progressTick.delete(baseId); + // Mark first so the downloadAsync() rejection below is understood as a pause. + useActiveDownloadsStore.getState().setStatus(baseId, 'paused'); try { - await entry.resumable.cancelAsync(); + await entry.resumable.pauseAsync(); } catch { - // ignore + // Pause failed (task already finishing, …) — the final stretch either + // completes the download or the task stays paused for a disk-offset resume. } +} + +/** + * Removes a leftover partial file so the next attempt starts clean. Returns + * the file's on-disk size when it exists (0 otherwise) — after a restart that + * size is the resume offset for rebuilding a resumable. + */ +async function partialFileInfo(fileUri: string): Promise { try { - const info = await FileSystem.getInfoAsync(entry.fileUri); - if (info.exists) { - await FileSystem.deleteAsync(entry.fileUri); - } + const info = await FileSystem.getInfoAsync(fileUri); + return info.exists && 'size' in info ? info.size : 0; } catch { - // ignore + return 0; } - useActiveDownloadsStore.getState().remove(baseId); } -/** Removes a leftover partial file so the next attempt starts clean. */ -async function deletePartialFile(fileUri: string): Promise { +/** + * Continue a failed OR paused download task (library row / watch button / + * tile menu). Resumes from the bytes already on disk — either through the + * resumable kept from the failed/paused attempt, or (after an app restart) by + * rebuilding one from the partial file's size. Falls back to a fresh download + * with a re-resolved source when resuming is not possible; returns false when + * no source can be resolved — the task stays errored and the caller should + * offer deletion instead. + */ +export async function retryDownload(baseId: string): Promise { + const task = useActiveDownloadsStore.getState().tasks[baseId]; + if (!task || (task.status !== 'error' && task.status !== 'paused')) return false; + + // A resumed task is no longer paused. + if (task.status === 'paused') { + useActiveDownloadsStore.getState().setStatus(baseId, 'downloading'); + } + + // 1) Resume an interrupted transfer where it stopped. + let entry = activeResumables.get(baseId); + if (!entry && task.videoUrl && task.videoId) { + // After an app restart there is no live resumable, but the partial file + // IS the resume point: its on-disk size is the byte offset (the native + // side truncates to it and sends `Range: bytes=-`). A too-low + // offset is safe; the file simply resumes from slightly earlier. + const fileUri = `${FileSystem.documentDirectory}${getDownloadPath()}/${task.videoId}.mp4`; + const partial = await partialFileInfo(fileUri); + if (partial > 0) { + const opts: DownloadVideoOptions = { + videoUrl: task.videoUrl, + videoId: task.videoId, + title: task.title, + thumbnail: task.thumbnail, + downloadPath: getDownloadPath(), + slug: task.slug, + uploader: task.uploader, + uploaderMemberId: task.uploaderMemberId, + }; + const resumable = new FileSystem.DownloadResumable( + task.videoUrl, + fileUri, + {}, + (dl) => reportProgress(baseId, dl.totalBytesWritten, dl.totalBytesExpectedToWrite), + String(partial) + ); + entry = { resumable, fileUri, opts }; + activeResumables.set(baseId, entry); + } + } + + if (entry) { + useActiveDownloadsStore.getState().setStatus(baseId, 'downloading'); + try { + const result = await entry.resumable.resumeAsync(); + await finalizeDownload(entry.opts, result); + activeResumables.delete(baseId); + return true; + } catch (error) { + // Resume didn't work (expired source URL, range rejected, …). Fall + // through to a fresh download; mark the failure so the row stays + // actionable if that path also dies immediately. + const current = useActiveDownloadsStore.getState().tasks[baseId]; + if (current && current.status !== 'cancelled' && current.status !== 'paused') { + useActiveDownloadsStore + .getState() + .fail(baseId, error instanceof Error ? error.message : 'Download failed'); + } + activeResumables.delete(baseId); + } + } + + // 2) Fresh download, preferring a newly resolved source URL. + const downloadPath = getDownloadPath(); + // Reset synchronously so a second tap (or the failure path below) always + // sees a consistent task. + useActiveDownloadsStore.getState().restart(baseId); try { - const info = await FileSystem.getInfoAsync(fileUri); - if (info.exists) { - await FileSystem.deleteAsync(fileUri); + const source = await resolveRetrySource(task); + await downloadVideo({ + videoUrl: source.videoUrl, + videoId: source.videoId, + title: task.title, + thumbnail: task.thumbnail, + downloadPath, + slug: task.slug, + uploader: task.uploader, + uploaderMemberId: task.uploaderMemberId, + }); + return true; + } catch (error) { + // resolveRetrySource failures don't pass through downloadVideo's own + // error handling — mark the task failed so the UI keeps offering retry. + const current = useActiveDownloadsStore.getState().tasks[baseId]; + if (current && current.status !== 'cancelled' && current.status !== 'paused') { + useActiveDownloadsStore + .getState() + .fail(baseId, error instanceof Error ? error.message : 'Download failed'); } - } catch { - // ignore — a partial file only wastes space, it doesn't block the retry + return false; } } @@ -214,41 +324,46 @@ async function resolveRetrySource( } /** - * Retries a failed download from the Downloads tab (or anywhere else that has - * the baseId). Only error tasks can be retried; the task keeps its row and - * runs through the normal progress → complete/fail lifecycle again. Errors are - * reported through the task state, not the returned promise. + * Cancels an in-flight download (from the active-downloads UI): cancels the + * resumable, deletes the partial file, and removes the active task. When there + * is no resumable (already errored/paused) the partial file is still deleted — + * this is also the "remove failed/paused task" path. */ -export async function retryDownload(baseId: string): Promise { - const task = useActiveDownloadsStore.getState().tasks[baseId]; - if (!task || task.status !== 'error') return; +export async function cancelDownload(baseId: string): Promise { + const entry = activeResumables.get(baseId); + // Signal cancellation first so the in-flight downloadVideo won't mark `fail`. + useActiveDownloadsStore.getState().setStatus(baseId, 'cancelled'); - // Reset synchronously so a second tap (or the failure path below) always - // sees a consistent task. - useActiveDownloadsStore.getState().restart(baseId); + if (!entry) { + // No resumable (task already errored/paused): still clear its partial file. + const task = useActiveDownloadsStore.getState().tasks[baseId]; + if (task?.videoId) { + const fileUri = `${FileSystem.documentDirectory}${getDownloadPath()}/${task.videoId}.mp4`; + try { + const info = await FileSystem.getInfoAsync(fileUri); + if (info.exists) await FileSystem.deleteAsync(fileUri, { idempotent: true }); + } catch { + // ignore + } + } + useActiveDownloadsStore.getState().remove(baseId); + return; + } - const downloadPath = getDownloadPath(); + activeResumables.delete(baseId); + progressTick.delete(baseId); try { - const source = await resolveRetrySource(task); - await deletePartialFile(`${FileSystem.documentDirectory}${downloadPath}/${source.videoId}.mp4`); - await downloadVideo({ - videoUrl: source.videoUrl, - videoId: source.videoId, - title: task.title, - thumbnail: task.thumbnail, - downloadPath, - slug: task.slug, - uploader: task.uploader, - uploaderMemberId: task.uploaderMemberId, - }); - } catch (error) { - // resolveRetrySource failures don't pass through downloadVideo's own - // error handling — mark the task failed so the UI keeps offering retry. - const current = useActiveDownloadsStore.getState().tasks[baseId]; - if (current && current.status !== 'cancelled') { - useActiveDownloadsStore - .getState() - .fail(baseId, error instanceof Error ? error.message : 'Download failed'); + await entry.resumable.cancelAsync(); + } catch { + // ignore + } + try { + const info = await FileSystem.getInfoAsync(entry.fileUri); + if (info.exists) { + await FileSystem.deleteAsync(entry.fileUri); } + } catch { + // ignore } + useActiveDownloadsStore.getState().remove(baseId); } diff --git a/src/lib/download/index.ts b/src/lib/download/index.ts index 27cde60..15a448e 100644 --- a/src/lib/download/index.ts +++ b/src/lib/download/index.ts @@ -37,10 +37,15 @@ export async function getAllDownloads(): Promise { /** * Deletes video files on disk that have no matching metadata entry ("orphans", - * e.g. from past interrupted downloads or a metadata reset). Returns the number - * of files removed. Best-effort: missing dir / unreadable files are ignored. + * e.g. from past downloads whose metadata was reset). Partial files belonging + * to an unfinished task are NOT orphans — they are the resume point, so their + * names must be passed in `excludeFileNames`. Returns the number of files + * removed. Best-effort: missing dir / unreadable files are ignored. */ -export async function reconcileDownloads(downloadPath = 'videos'): Promise { +export async function reconcileDownloads( + downloadPath = 'videos', + excludeFileNames: Set = new Set() +): Promise { const store = getDownloadStore(); const entries = Object.values(store); const firstUri = entries[0]?.uri; @@ -53,7 +58,7 @@ export async function reconcileDownloads(downloadPath = 'videos'): Promise stay silent. - if (!task || task.status === 'cancelled') return; + // Cancelled or paused (by the user) -> user action, not a failure. + // On some Android versions pauseAsync settles downloadAsync without a + // result; that rejection must not relabel the paused task as errored. + if (!task || task.status === 'cancelled' || task.status === 'paused') return; const msg = error instanceof Error ? error.message : 'Download failed'; // Failure before downloadVideo ran (e.g. detail fetch) -> mark it failed here. if (task.status !== 'error') { diff --git a/src/lib/hooks/use-video-download.ts b/src/lib/hooks/use-video-download.ts index ae1965c..cd1effb 100644 --- a/src/lib/hooks/use-video-download.ts +++ b/src/lib/hooks/use-video-download.ts @@ -1,10 +1,9 @@ import * as FileSystem from 'expo-file-system/legacy'; -import * as React from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; -import { downloadVideo } from '@/lib/download/download-video'; -import { useActiveDownloadsStore } from '@/lib/stores/active-downloads-store'; -import { baseIdOf } from '@/lib/stores/downloaded-store'; +import { downloadVideo, retryDownload } from '@/lib/download/download-video'; +import { useActiveDownload, useActiveDownloadsStore } from '@/lib/stores/active-downloads-store'; +import { baseIdOf, useDownloadedStore } from '@/lib/stores/downloaded-store'; import { useDownloadSettings } from './use-download-settings'; @@ -18,6 +17,12 @@ type UseVideoDownloadProps = { videoUploaderMemberId?: string; }; +/** + * Post-page download button state. Everything reactive (progress, error, + * in-flight) is derived from the active-downloads store so the button, the + * library row and the tile badges all tell the same story — including a retry + * resuming mid-file. + */ export const useVideoDownload = ({ videoUrl, videoId, @@ -27,31 +32,36 @@ export const useVideoDownload = ({ videoUploader, videoUploaderMemberId, }: UseVideoDownloadProps) => { - const [isDownloading, setIsDownloading] = useState(false); - const [downloadProgress, setDownloadProgress] = useState(0); - const [isDownloaded, setIsDownloaded] = useState(false); - const [error, setError] = React.useState(null); - const { downloadPath } = useDownloadSettings(); const baseId = baseIdOf(videoId); const fileUri = `${FileSystem.documentDirectory}${downloadPath}/${videoId}.mp4`; + const isDownloaded = useDownloadedStore((s) => s.downloadedBaseIds.has(baseId)); + const active = useActiveDownload(baseId); - React.useEffect(() => { - const checkFile = async () => { - try { - const fileInfo = await FileSystem.getInfoAsync(fileUri); - setIsDownloaded(fileInfo.exists); - } catch (e) { - console.error('Error checking file existence:', e); - } - }; - checkFile(); - }, [fileUri]); + const isDownloading = + !!active && (active.status === 'downloading' || active.status === 'preparing'); + const downloadProgress = active + ? active.progress < 0 + ? 0 + : active.progress + : isDownloaded + ? 1 + : 0; + const error = active?.status === 'error' ? new Error(active.error ?? 'Download failed') : null; const handleDownload = useCallback(async () => { - if (isDownloading || !videoUrl) return; - if (useActiveDownloadsStore.getState().tasks[baseId]) return; // already active + if (!videoUrl) return; + const existing = useActiveDownloadsStore.getState().tasks[baseId]; + if (existing && existing.status !== 'error' && existing.status !== 'paused') { + return; // already active + } + // An errored or paused task continues (resuming from the bytes already + // on disk when the previous attempt stopped mid-transfer). + if (existing) { + await retryDownload(baseId); + return; + } // Surface immediately so the active-downloads list / tile badge light up now. // The full source (url/quality/uploader) is captured by downloadVideo so a @@ -66,9 +76,6 @@ export const useVideoDownload = ({ }); try { - setError(null); - setIsDownloading(true); - await downloadVideo({ videoUrl, videoId, @@ -78,23 +85,19 @@ export const useVideoDownload = ({ slug: videoSlug, uploader: videoUploader, uploaderMemberId: videoUploaderMemberId, - onProgress: setDownloadProgress, }); - setIsDownloaded(true); } catch (e) { const task = useActiveDownloadsStore.getState().tasks[baseId]; - if (!task || task.status === 'cancelled') return; // cancelled -> silent + // Cancelled or paused (by the user) -> user action, not a failure. + // On some Android versions pauseAsync settles downloadAsync without a + // result; that rejection must not relabel the paused task as errored. + if (!task || task.status === 'cancelled' || task.status === 'paused') return; const msg = e instanceof Error ? e.message : 'Download failed'; if (task.status !== 'error') { useActiveDownloadsStore.getState().fail(baseId, msg); } - setError(e instanceof Error ? e : new Error('Download failed')); - } finally { - setIsDownloading(false); - setDownloadProgress(0); } }, [ - isDownloading, videoUrl, videoId, baseId, diff --git a/src/lib/stores/active-downloads-store.test.ts b/src/lib/stores/active-downloads-store.test.ts index d96fa16..50ff5d5 100644 --- a/src/lib/stores/active-downloads-store.test.ts +++ b/src/lib/stores/active-downloads-store.test.ts @@ -121,6 +121,13 @@ describe('unfinished-download persistence', () => { expect(persistedTasks()['2'].status).toBe('downloading'); }); + it('persists paused tasks so they survive an app restart as paused', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); + useActiveDownloadsStore.getState().setStatus('1', 'paused'); + + expect(persistedTasks()['1'].status).toBe('paused'); + }); + it('keeps the source fields so the failure stays retryable after a restart', () => { useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); useActiveDownloadsStore @@ -147,6 +154,24 @@ describe('unfinished-download persistence', () => { expect(persistedTasks()).toEqual({}); }); + it('restores a user-paused task as paused (it did not die with the process)', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); + useActiveDownloadsStore + .getState() + .setSource('1', { videoUrl: 'https://cdn/v.mp4', videoId: '1_720p', quality: '720p' }); + useActiveDownloadsStore.getState().setProgress('1', 0.47, 47, 100); + useActiveDownloadsStore.getState().setStatus('1', 'paused'); + // Simulate a restart: fresh in-memory store, storage still populated. + useActiveDownloadsStore.setState({ tasks: {} }); + + restoreUnfinishedDownloads(); + + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.status).toBe('paused'); + // The on-disk bytes are the resume point, so the progress is kept too. + expect(task?.progress).toBe(0.47); + }); + it('restores an interrupted in-flight task as a retryable "Interrupted" error', () => { useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); useActiveDownloadsStore diff --git a/src/lib/stores/active-downloads-store.ts b/src/lib/stores/active-downloads-store.ts index 4c565ec..b9ce6b2 100644 --- a/src/lib/stores/active-downloads-store.ts +++ b/src/lib/stores/active-downloads-store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { getItem, setItem } from '@/lib/storage'; -export type ActiveDownloadStatus = 'preparing' | 'downloading' | 'error' | 'cancelled'; +export type ActiveDownloadStatus = 'preparing' | 'downloading' | 'paused' | 'error' | 'cancelled'; export type ActiveDownload = { baseId: string; @@ -73,10 +73,13 @@ function persistUnfinished(tasks: Record): void { /** * Restores persisted tasks into the store (no-op when there are none). Called - * once on app start, before the Downloads tab can be shown. Error tasks come - * back with their message; in-flight tasks come back marked "Interrupted" and - * reset, ready for Retry. Tasks without any way to resolve a download source - * (neither a stored URL nor a slug) are dropped. + * once on app start, before the Downloads tab can be shown — and before the + * orphan cleanup, so partial files of interrupted/paused tasks are spared + * (their on-disk bytes are the resume point). Error tasks come back with + * their message; user-paused tasks stay paused; in-flight (`preparing`/ + * `downloading`) tasks died with the process and come back marked + * "Interrupted", ready for Retry. Tasks without any way to resolve a download + * source (neither a stored URL nor a slug) are dropped. */ export function restoreUnfinishedDownloads(): void { const saved = getItem>(UNFINISHED_TASKS_KEY) ?? {}; @@ -84,8 +87,8 @@ export function restoreUnfinishedDownloads(): void { Object.entries(saved) .filter(([, task]) => task?.baseId && (task.videoUrl || task.slug)) .map(([id, task]): [string, ActiveDownload] => - task.status === 'error' - ? [id, task] + task.status === 'error' || task.status === 'paused' + ? [id, task] // already actionable (retry / resume) — keep as-is : [ id, { diff --git a/src/lib/stores/downloaded-store.ts b/src/lib/stores/downloaded-store.ts index 77b25c4..e37375a 100644 --- a/src/lib/stores/downloaded-store.ts +++ b/src/lib/stores/downloaded-store.ts @@ -7,6 +7,7 @@ import { reconcileDownloads, } from '@/lib/download'; import type { DownloadMetadata } from '@/lib/download'; +import { restoreUnfinishedDownloads, useActiveDownloadsStore } from './active-downloads-store'; /** * Composite download keys are `${baseId}_${quality}` (e.g. `12345_720p`), built @@ -36,9 +37,16 @@ export const useDownloadedStore = create((set) => ({ loaded: false, hydrate: async () => { - // Clean orphaned files (no metadata) before reading, so the list reflects - // the true on-disk state. - await reconcileDownloads(); + // Tasks persisted as in-flight died with the previous process — mark them + // retryable first, so the orphan cleanup below spares their partial files + // (those are the resume points). + restoreUnfinishedDownloads(); + const activeFiles = new Set( + Object.values(useActiveDownloadsStore.getState().tasks) + .filter((task) => task.videoId) + .map((task) => `${task.videoId}.mp4`) + ); + await reconcileDownloads('videos', activeFiles); const all = await getAllDownloads(); set({ entries: all, diff --git a/src/translations/en.json b/src/translations/en.json index 6189664..4a76658 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5,6 +5,8 @@ "delete": "Delete", "done": "Done", "error": "Error", + "pause": "Pause", + "resume": "Resume", "retry": "Retry" }, "tabs": { @@ -102,6 +104,7 @@ "status_downloading": "Downloading · {{progress}}%", "status_downloading_indeterminate": "Downloading…", "status_failed": "Failed{{error}}", + "status_paused": "Paused", "tap_retry_hint": " — tap Retry to try again", "title": "Library" }, diff --git a/src/translations/es.json b/src/translations/es.json index 2a4b78e..4ced06f 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -5,6 +5,8 @@ "delete": "Eliminar", "done": "Listo", "error": "Error", + "pause": "Pausar", + "resume": "Reanudar", "retry": "Reintentar" }, "tabs": { @@ -102,6 +104,7 @@ "status_downloading": "Descargando · {{progress}}%", "status_downloading_indeterminate": "Descargando…", "status_failed": "Error{{error}}", + "status_paused": "En pausa", "tap_retry_hint": " — toca Reintentar para volver a intentarlo", "title": "Biblioteca" }, diff --git a/src/translations/ja.json b/src/translations/ja.json index d8be731..da3d7b8 100644 --- a/src/translations/ja.json +++ b/src/translations/ja.json @@ -5,6 +5,8 @@ "delete": "削除", "done": "完了", "error": "エラー", + "pause": "一時停止", + "resume": "再開", "retry": "再試行" }, "tabs": { @@ -102,6 +104,7 @@ "status_downloading": "ダウンロード中 · {{progress}}%", "status_downloading_indeterminate": "ダウンロード中…", "status_failed": "失敗{{error}}", + "status_paused": "一時停止中", "tap_retry_hint": " — 「再試行」をタップしてもう一度", "title": "ライブラリ" }, diff --git a/src/translations/ko.json b/src/translations/ko.json index 34b872b..97a75d4 100644 --- a/src/translations/ko.json +++ b/src/translations/ko.json @@ -5,6 +5,8 @@ "delete": "삭제", "done": "완료", "error": "오류", + "pause": "일시정지", + "resume": "계속", "retry": "재시도" }, "tabs": { @@ -102,6 +104,7 @@ "status_downloading": "다운로드 중 · {{progress}}%", "status_downloading_indeterminate": "다운로드 중…", "status_failed": "실패{{error}}", + "status_paused": "일시정지됨", "tap_retry_hint": " — 다시 시도를 눌러 재시도하세요", "title": "보관함" }, diff --git a/src/translations/pt.json b/src/translations/pt.json index 251fac2..f07bb9c 100644 --- a/src/translations/pt.json +++ b/src/translations/pt.json @@ -5,6 +5,8 @@ "delete": "Excluir", "done": "Concluído", "error": "Erro", + "pause": "Pausar", + "resume": "Retomar", "retry": "Tentar novamente" }, "tabs": { @@ -102,6 +104,7 @@ "status_downloading": "Baixando · {{progress}}%", "status_downloading_indeterminate": "Baixando…", "status_failed": "Falhou{{error}}", + "status_paused": "Pausado", "tap_retry_hint": " — toque em Repetir para tentar de novo", "title": "Biblioteca" }, diff --git a/src/translations/zh-TW.json b/src/translations/zh-TW.json index c683526..7a60be9 100644 --- a/src/translations/zh-TW.json +++ b/src/translations/zh-TW.json @@ -5,6 +5,8 @@ "delete": "刪除", "done": "完成", "error": "錯誤", + "pause": "暫停", + "resume": "繼續", "retry": "重試" }, "tabs": { @@ -102,6 +104,7 @@ "status_downloading": "下載中 · {{progress}}%", "status_downloading_indeterminate": "下載中…", "status_failed": "失敗{{error}}", + "status_paused": "已暫停", "tap_retry_hint": " — 點擊重試再試一次", "title": "媒體庫" }, diff --git a/src/translations/zh.json b/src/translations/zh.json index eeecea4..aae77ba 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -5,6 +5,8 @@ "delete": "删除", "done": "完成", "error": "错误", + "pause": "暂停", + "resume": "继续", "retry": "重试" }, "tabs": { @@ -102,6 +104,7 @@ "status_downloading": "下载中 · {{progress}}%", "status_downloading_indeterminate": "下载中…", "status_failed": "失败{{error}}", + "status_paused": "已暂停", "tap_retry_hint": " — 点击重试再试一次", "title": "媒体库" },