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": "媒体库"
},