diff --git a/src/app/(app)/library.tsx b/src/app/(app)/library.tsx
index 8c7bc1d..f7ee7e2 100644
--- a/src/app/(app)/library.tsx
+++ b/src/app/(app)/library.tsx
@@ -4,7 +4,7 @@ import { StyledImage } from '@/components/native-styled';
import { SafeAreaView } from '@/components/safe-area-view';
import { VideoGrid } from '@/components/video-grid';
import type { DownloadMetadata } from '@/lib/download';
-import { cancelDownload } from '@/lib/download/download-video';
+import { cancelDownload, pauseDownload, retryDownload } from '@/lib/download/download-video';
import type { TxKeyPath } from '@/lib/i18n/types';
import { useTranslate } from '@/lib/i18n/utils';
import { useDownloadedStore, useFavoritesStore, useHistoryStore } from '@/lib/stores';
@@ -95,24 +95,44 @@ function formatDate(ts: number): string {
function ActiveDownloadRow({ task }: { task: ActiveDownload }) {
const t = useTranslate();
- const [cancelling, setCancelling] = useState(false);
+ const [busy, setBusy] = useState(false);
const indeterminate = task.progress < 0;
const pct = indeterminate ? 0 : Math.round(task.progress * 100);
const barWidth = indeterminate ? 40 : pct;
+ const errored = task.status === 'error';
+ const paused = task.status === 'paused';
const onCancel = async () => {
- setCancelling(true);
+ setBusy(true);
await cancelDownload(task.videoId);
};
+ // A failed task is removed (and its partial file cleaned) via the same
+ // cancel path — there is no resumable left, it just clears the task.
+ const onRetry = async () => {
+ setBusy(true);
+ const restarted = await retryDownload(task.videoId);
+ // false = still offline / source gone; the task stays errored so the
+ // user can delete it instead.
+ if (!restarted) setBusy(false);
+ };
+
+ const onPause = async () => {
+ setBusy(true);
+ await pauseDownload(task.videoId);
+ setBusy(false);
+ };
+
const statusText =
task.status === 'error'
? `${t('library.failed')}${task.error ? `: ${task.error}` : ''}`
- : task.status === 'cancelled'
- ? t('library.cancelled')
- : indeterminate
- ? `${t('library.downloading')}…`
- : `${t('library.downloading')} · ${pct}%`;
+ : paused
+ ? t('library.paused')
+ : task.status === 'cancelled'
+ ? t('library.cancelled')
+ : indeterminate
+ ? `${t('library.downloading')}…`
+ : `${t('library.downloading')} · ${pct}%`;
return (
@@ -128,21 +148,60 @@ function ActiveDownloadRow({ task }: { task: ActiveDownload }) {
{statusText}
-
-
- {cancelling ? '…' : t('common.cancel')}
-
-
+ {errored || paused ? (
+
+
+
+ {busy ? '…' : t('common.resume')}
+
+
+
+ {t('common.delete')}
+
+
+ ) : (
+
+
+ {t('common.pause')}
+
+
+
+ {busy ? '…' : t('common.cancel')}
+
+
+
+ )}
);
}
diff --git a/src/app/watch/[id].tsx b/src/app/watch/[id].tsx
index bab504c..df7833f 100644
--- a/src/app/watch/[id].tsx
+++ b/src/app/watch/[id].tsx
@@ -337,7 +337,7 @@ function DownloadButton({ state }: { state: ReturnType
className="flex-row items-center gap-1.5 rounded-full bg-destructive px-3 py-1.5"
>
- {t('detail.retry')}
+ {t('common.resume')}
);
}
diff --git a/src/components/video-tile.tsx b/src/components/video-tile.tsx
index 0dd91ca..c3732b8 100644
--- a/src/components/video-tile.tsx
+++ b/src/components/video-tile.tsx
@@ -1,5 +1,6 @@
import { Icon } from '@/components/icon';
import { StyledImage } from '@/components/native-styled';
+import { retryDownload } from '@/lib/download/download-video';
import { type CardOrientation, cardOrientation } from '@/lib/hanime1/images';
import { buildUrl, endpoints } from '@/lib/hanime1/scraper';
import { useVideoActions } from '@/lib/hooks';
@@ -87,9 +88,19 @@ function VideoTileBase({
},
{
id: 'download',
- title: isActive ? 'Downloading…' : isDownloaded ? 'Downloaded' : 'Download',
+ title:
+ active?.status === 'error' || active?.status === 'paused'
+ ? 'Resume download'
+ : isActive
+ ? 'Downloading…'
+ : isDownloaded
+ ? 'Downloaded'
+ : 'Download',
image: 'arrow.down.circle',
- attributes: isActive || isDownloaded ? { disabled: true } : undefined,
+ attributes:
+ isActive && active?.status !== 'error' && active?.status !== 'paused'
+ ? { disabled: true }
+ : undefined,
},
{
id: 'share',
@@ -103,7 +114,11 @@ function VideoTileBase({
if (ev === 'favorite') {
toggleFavorite();
} else if (ev === 'download') {
- void toggleDownload();
+ if (active?.status === 'error' || active?.status === 'paused') {
+ void retryDownload(item.id);
+ } else {
+ void toggleDownload();
+ }
} else if (ev === 'share') {
void Share.share({
url: buildUrl(endpoints.watch(item.id)),
diff --git a/src/lib/download/download-video.ts b/src/lib/download/download-video.ts
index ec2912f..65da6e6 100644
--- a/src/lib/download/download-video.ts
+++ b/src/lib/download/download-video.ts
@@ -1,3 +1,4 @@
+import { fetchVideoDetail } from '@/lib/hanime1/scraper';
import { useActiveDownloadsStore } from '@/lib/stores/active-downloads-store';
import { useDownloadedStore } from '@/lib/stores/downloaded-store';
import * as FileSystem from 'expo-file-system/legacy';
@@ -20,10 +21,15 @@ export type DownloadVideoOptions = {
onProgress?: (ratio: number) => void;
};
-/** Active resumables so an in-flight download can be cancelled. Keyed by videoId. */
+/**
+ * Live resumables, keyed by videoId. Kept on FAILURE too — that is what makes
+ * a retry resumable: `resumeAsync()` continues from the bytes already on disk
+ * (the signed CDN URLs honour Range). Entries are dropped on success, cancel
+ * and app restart (resume state is in-memory only).
+ */
const activeResumables = new Map<
string,
- { resumable: FileSystem.DownloadResumable; fileUri: string }
+ { resumable: FileSystem.DownloadResumable; fileUri: string; opts: DownloadVideoOptions }
>();
/** Per-videoId throttle state for progress updates (avoids a render storm). */
@@ -40,6 +46,46 @@ function reportProgress(videoId: string, written: number, expected: number) {
.setProgress(videoId, written / Math.max(1, expected), written, expected);
}
+type DownloadResult = FileSystem.FileSystemDownloadResult;
+
+/** Shared success path for downloadAsync() and resumeAsync() results. */
+async function finalizeDownload(
+ opts: DownloadVideoOptions,
+ result: DownloadResult | undefined
+): Promise {
+ const { videoId, title, thumbnail, resolution, author } = opts;
+ if (!result || result.status < 200 || result.status >= 300) {
+ throw new Error(`Download failed (status ${result?.status ?? 'unknown'})`);
+ }
+ // If the user cancelled during the final stretch, don't register; drop the file.
+ if (useActiveDownloadsStore.getState().tasks[videoId]?.status === 'cancelled') {
+ try {
+ await FileSystem.deleteAsync(result.uri);
+ } catch {
+ // ignore
+ }
+ throw new Error('Download cancelled');
+ }
+
+ const meta: DownloadMetadata = {
+ videoId,
+ title,
+ thumbnail,
+ uri: result.uri,
+ size: result.headers?.['Content-Length']
+ ? Number.parseInt(String(result.headers['Content-Length']), 10)
+ : 0,
+ resolution,
+ downloadedAt: Date.now(),
+ author,
+ };
+ saveDownloadMetadata(meta);
+ // Keep the reactive store in sync so badges light up across the app.
+ useDownloadedStore.getState().register(meta);
+ useActiveDownloadsStore.getState().complete(videoId);
+ return meta;
+}
+
/**
* Downloads a single video to the on-device `videos/` directory, persists its
* metadata, and keeps both the reactive completed store and the active-downloads
@@ -52,10 +98,19 @@ function reportProgress(videoId: string, written: number, expected: number) {
* resolution overwrites the same file).
*/
export async function downloadVideo(opts: DownloadVideoOptions): Promise {
- const { videoId, videoUrl, title, thumbnail, resolution, author, onProgress } = opts;
+ const { videoId, videoUrl, onProgress } = opts;
await ensureDownloadDir();
const fileUri = localUriFor(videoId);
+ // Drop any partial file from an earlier interrupted attempt so this always
+ // starts clean — this is the from-scratch path, resume goes through the
+ // resumable kept from the failed attempt instead.
+ try {
+ const stale = await FileSystem.getInfoAsync(fileUri);
+ if (stale.exists) await FileSystem.deleteAsync(fileUri, { idempotent: true });
+ } catch {
+ // ignore — worst case the resumable overwrites it
+ }
const resumable = FileSystem.createDownloadResumable(videoUrl, fileUri, {}, (dl) => {
const written = dl.totalBytesWritten;
@@ -63,61 +118,174 @@ export async function downloadVideo(opts: DownloadVideoOptions): Promise 0 ? written / expected : 0);
reportProgress(videoId, written, expected);
});
- activeResumables.set(videoId, { resumable, fileUri });
+ activeResumables.set(videoId, { resumable, fileUri, opts });
try {
useActiveDownloadsStore.getState().setStatus(videoId, 'downloading');
const result = await resumable.downloadAsync();
- if (!result || result.status < 200 || result.status >= 300) {
- throw new Error(`Download failed (status ${result?.status ?? 'unknown'})`);
- }
- // If the user cancelled during the final stretch, don't register; drop the file.
- if (useActiveDownloadsStore.getState().tasks[videoId]?.status === 'cancelled') {
- try {
- await FileSystem.deleteAsync(result.uri);
- } catch {
- // ignore
- }
- throw new Error('Download cancelled');
- }
-
- const meta: DownloadMetadata = {
- videoId,
- title,
- thumbnail,
- uri: result.uri,
- size: result.headers?.['Content-Length']
- ? Number.parseInt(String(result.headers['Content-Length']), 10)
- : 0,
- resolution,
- downloadedAt: Date.now(),
- author,
- };
- saveDownloadMetadata(meta);
- // Keep the reactive store in sync so badges light up across the app.
- useDownloadedStore.getState().register(meta);
- useActiveDownloadsStore.getState().complete(videoId);
- return meta;
+ return await finalizeDownload(opts, result);
} catch (error) {
- // If the task was already removed/marked cancelled, this was a user cancel,
- // not a failure — don't surface it as an error.
+ // If the task was already removed/marked cancelled/paused, this was user
+ // action, not a failure — don't surface it as an error.
const task = useActiveDownloadsStore.getState().tasks[videoId];
- if (task && task.status !== 'cancelled') {
+ if (task && task.status !== 'cancelled' && task.status !== 'paused') {
useActiveDownloadsStore
.getState()
.fail(videoId, error instanceof Error ? error.message : 'Download failed');
}
throw error;
} finally {
- activeResumables.delete(videoId);
+ // Keep the resumable (and its on-disk bytes) on failure AND pause so the
+ // task can resume from where it stopped; drop it otherwise.
+ const status = useActiveDownloadsStore.getState().tasks[videoId]?.status;
+ if (status !== 'error' && status !== 'paused') activeResumables.delete(videoId);
progressTick.delete(videoId);
}
}
+/**
+ * Pauses an in-flight download (from the library row). pauseAsync stops the
+ * native task at a clean boundary and captures the exact resume offset; the
+ * task stays in the table as `paused` with its resumable kept for resuming.
+ */
+export async function pauseDownload(videoId: string): Promise {
+ const entry = activeResumables.get(videoId);
+ const task = useActiveDownloadsStore.getState().tasks[videoId];
+ if (!entry || !task || task.status !== 'downloading') return;
+
+ // Mark first so the downloadAsync() rejection below is understood as a pause.
+ useActiveDownloadsStore.getState().setStatus(videoId, 'paused');
+ try {
+ await entry.resumable.pauseAsync();
+ } catch {
+ // Pause failed (task already finishing, …) — the final stretch either
+ // completes the download or the task stays paused for a disk-offset resume.
+ }
+}
+
+/**
+ * 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(videoId: string): Promise {
+ const task = useActiveDownloadsStore.getState().tasks[videoId];
+ if (!task || (task.status !== 'error' && task.status !== 'paused')) return false;
+
+ // A resumed task is no longer paused.
+ if (task.status === 'paused') {
+ useActiveDownloadsStore.getState().setStatus(videoId, 'downloading');
+ }
+
+ // 1) Resume an interrupted transfer where it stopped.
+ let entry = activeResumables.get(videoId);
+ if (!entry && task.videoUrl) {
+ // 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.
+ try {
+ const fileUri = localUriFor(videoId);
+ const info = await FileSystem.getInfoAsync(fileUri);
+ const partial = info.exists && 'size' in info ? info.size : 0;
+ if (partial > 0) {
+ const opts: DownloadVideoOptions = {
+ videoId,
+ videoUrl: task.videoUrl,
+ title: task.title,
+ thumbnail: task.thumbnail,
+ resolution: task.resolution ?? 0,
+ author: task.author,
+ };
+ const resumable = new FileSystem.DownloadResumable(
+ task.videoUrl,
+ fileUri,
+ {},
+ (dl) => reportProgress(videoId, dl.totalBytesWritten, dl.totalBytesExpectedToWrite),
+ String(partial)
+ );
+ entry = { resumable, fileUri, opts };
+ activeResumables.set(videoId, entry);
+ }
+ } catch {
+ // fall through to the fresh-download path
+ }
+ }
+
+ if (entry) {
+ useActiveDownloadsStore.getState().setStatus(videoId, 'downloading');
+ try {
+ const result = await entry.resumable.resumeAsync();
+ await finalizeDownload(entry.opts, result);
+ activeResumables.delete(videoId);
+ return true;
+ } catch (error) {
+ // Resume didn't work (expired signed URL, range rejected, …). Fall
+ // through to a fresh download; mark the failure so the row stays
+ // actionable if that path also dies immediately.
+ const t = useActiveDownloadsStore.getState().tasks[videoId];
+ if (t && t.status !== 'cancelled') {
+ useActiveDownloadsStore
+ .getState()
+ .fail(videoId, error instanceof Error ? error.message : 'Download failed');
+ }
+ activeResumables.delete(videoId);
+ }
+ }
+
+ // 2) Fresh download, preferring a newly signed source URL.
+ let videoUrl = task.videoUrl;
+ let resolution = task.resolution ?? 0;
+ try {
+ const detail = await fetchVideoDetail(videoId);
+ const source = detail.sources.find((s) => s.resolution === 720) ?? detail.sources[0];
+ if (source) {
+ videoUrl = source.url;
+ resolution = source.resolution;
+ }
+ } catch {
+ // Still offline / detail gone — fall back to the recorded source.
+ }
+ if (!videoUrl) {
+ // No source anywhere: keep the task errored; deletion remains.
+ return false;
+ }
+
+ // Restart the task in place so badges immediately flip back to downloading.
+ useActiveDownloadsStore.getState().start({
+ videoId,
+ title: task.title,
+ thumbnail: task.thumbnail,
+ author: task.author,
+ videoUrl,
+ resolution,
+ });
+
+ try {
+ await downloadVideo({
+ videoId,
+ videoUrl,
+ title: task.title,
+ thumbnail: task.thumbnail,
+ resolution,
+ author: task.author,
+ });
+ return true;
+ } catch {
+ // downloadVideo already re-marked the task as failed.
+ return false;
+ }
+}
+
/**
* Cancels an in-flight download (from the active-downloads UI): cancels the
- * resumable, deletes the partial file, and removes the active task. If the
- * download already finished (not in the registry) this just clears the task.
+ * resumable, deletes the partial file, and removes the active task. When there
+ * is no resumable (already errored) the partial file is still deleted — this is
+ * also the "remove failed task" path.
*/
export async function cancelDownload(videoId: string): Promise {
const entry = activeResumables.get(videoId);
@@ -125,6 +293,13 @@ export async function cancelDownload(videoId: string): Promise {
useActiveDownloadsStore.getState().setStatus(videoId, 'cancelled');
if (!entry) {
+ // No resumable (task already errored): still clear its partial file.
+ try {
+ const info = await FileSystem.getInfoAsync(localUriFor(videoId));
+ if (info.exists) await FileSystem.deleteAsync(localUriFor(videoId), { idempotent: true });
+ } catch {
+ // ignore
+ }
useActiveDownloadsStore.getState().remove(videoId);
return;
}
diff --git a/src/lib/download/index.ts b/src/lib/download/index.ts
index cb4a851..f44273f 100644
--- a/src/lib/download/index.ts
+++ b/src/lib/download/index.ts
@@ -77,10 +77,11 @@ export function localUriFor(videoId: string): string {
/**
* Orphan cleanup: deletes on-disk `.mp4` files in the videos/ directory that
- * have no metadata entry (left over from interrupted or legacy downloads).
- * Returns the videoIds that were removed. Safe to run repeatedly.
+ * have no metadata entry and no active task (a half-download belonging to a
+ * persisted task is the resume point — it must survive). Returns the videoIds
+ * that were removed. Safe to run repeatedly.
*/
-export async function reconcileDownloads(): Promise {
+export async function reconcileDownloads(excludeIds: Set = new Set()): Promise {
const map = readMap();
const removed: string[] = [];
let dirInfo: FileSystem.FileInfo;
@@ -101,7 +102,7 @@ export async function reconcileDownloads(): Promise {
for (const name of entries) {
if (!name.endsWith('.mp4')) continue;
const videoId = name.slice(0, -'.mp4'.length);
- if (!(videoId in map)) {
+ if (!(videoId in map) && !excludeIds.has(videoId)) {
try {
await FileSystem.deleteAsync(`${DOWNLOAD_DIR}${name}`, { idempotent: true });
removed.push(videoId);
diff --git a/src/lib/hooks/use-video-actions.ts b/src/lib/hooks/use-video-actions.ts
index 53a75de..4bdf5fc 100644
--- a/src/lib/hooks/use-video-actions.ts
+++ b/src/lib/hooks/use-video-actions.ts
@@ -66,8 +66,8 @@ export function useVideoActions(item: ActionableItem) {
showMessage({ message: 'Downloaded', type: 'success', position: 'top' });
} catch (error) {
const task = useActiveDownloadsStore.getState().tasks[item.id];
- // Cancelled (task removed or marked) -> stay silent.
- if (!task || task.status === 'cancelled') return;
+ // Cancelled/paused (user action) -> stay silent.
+ 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 2fc5bfb..0863b65 100644
--- a/src/lib/hooks/use-video-download.ts
+++ b/src/lib/hooks/use-video-download.ts
@@ -1,9 +1,8 @@
import { localUriFor } from '@/lib/download';
-import { downloadVideo } from '@/lib/download/download-video';
-import { useActiveDownloadsStore } from '@/lib/stores/active-downloads-store';
+import { downloadVideo, retryDownload } from '@/lib/download/download-video';
+import { useActiveDownload, useActiveDownloadsStore } from '@/lib/stores/active-downloads-store';
import { useDownloadedStore } from '@/lib/stores/downloaded-store';
-import * as FileSystem from 'expo-file-system/legacy';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback } from 'react';
export type UseVideoDownloadOptions = {
videoId: string;
@@ -23,27 +22,40 @@ export type VideoDownloadState = {
handleDownload: () => Promise;
};
+/**
+ * Watch-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 function useVideoDownload(opts: UseVideoDownloadOptions): VideoDownloadState {
const fileUri = localUriFor(opts.videoId);
const isDownloaded = useDownloadedStore((s) => s.has(opts.videoId));
+ const active = useActiveDownload(opts.videoId);
- const [isDownloading, setIsDownloading] = useState(false);
- const [downloadProgress, setDownloadProgress] = useState(0);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- let active = true;
- FileSystem.getInfoAsync(fileUri).then((info) => {
- if (active && info.exists) setDownloadProgress(1);
- });
- return () => {
- active = false;
- };
- }, [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' ? (active.error ?? 'Download failed') : null;
const handleDownload = useCallback(async () => {
- if (!opts.videoUrl || isDownloading) return;
- if (useActiveDownloadsStore.getState().tasks[opts.videoId]) return; // already active
+ if (!opts.videoUrl) return;
+ const existing = useActiveDownloadsStore.getState().tasks[opts.videoId];
+ 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(opts.videoId);
+ return;
+ }
// Surface immediately so the active-downloads list / tile badge light up now.
useActiveDownloadsStore.getState().start({
@@ -51,12 +63,11 @@ export function useVideoDownload(opts: UseVideoDownloadOptions): VideoDownloadSt
title: opts.title,
thumbnail: opts.thumbnail,
author: opts.author,
+ videoUrl: opts.videoUrl,
+ resolution: opts.resolution,
});
try {
- setError(null);
- setIsDownloading(true);
- setDownloadProgress(0);
await downloadVideo({
videoId: opts.videoId,
videoUrl: opts.videoUrl,
@@ -64,9 +75,7 @@ export function useVideoDownload(opts: UseVideoDownloadOptions): VideoDownloadSt
thumbnail: opts.thumbnail,
resolution: opts.resolution,
author: opts.author,
- onProgress: (ratio) => setDownloadProgress(ratio),
});
- setDownloadProgress(1);
} catch (e) {
const task = useActiveDownloadsStore.getState().tasks[opts.videoId];
if (!task || task.status === 'cancelled') return; // cancelled -> silent
@@ -74,11 +83,8 @@ export function useVideoDownload(opts: UseVideoDownloadOptions): VideoDownloadSt
if (task.status !== 'error') {
useActiveDownloadsStore.getState().fail(opts.videoId, msg);
}
- setError(msg);
- } finally {
- setIsDownloading(false);
}
- }, [opts, isDownloading]);
+ }, [opts]);
return {
isDownloading,
diff --git a/src/lib/stores/active-downloads-store.ts b/src/lib/stores/active-downloads-store.ts
index 35c1bd1..e694714 100644
--- a/src/lib/stores/active-downloads-store.ts
+++ b/src/lib/stores/active-downloads-store.ts
@@ -1,12 +1,21 @@
+import { mmkvStateStorage } from '@/lib/stores/mmkv-storage';
import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
-export type ActiveDownloadStatus = 'preparing' | 'downloading' | 'error' | 'cancelled';
+export type ActiveDownloadStatus = 'preparing' | 'downloading' | 'paused' | 'error' | 'cancelled';
export type ActiveDownload = {
videoId: string;
title: string;
thumbnail: string;
author?: string;
+ /**
+ * Source URL + resolution of the in-flight download, filled in as soon as
+ * they are known. Kept on the task so a failed download can be retried
+ * without re-resolving the detail page.
+ */
+ videoUrl?: string;
+ resolution?: number;
/** Download progress 0..1, or -1 when the total size is unknown (indeterminate). */
progress: number;
status: ActiveDownloadStatus;
@@ -21,77 +30,119 @@ type StartInput = {
title: string;
thumbnail: string;
author?: string;
+ videoUrl?: string;
+ resolution?: number;
};
type ActiveDownloadsState = {
/** Keyed by video id so an entry can be created the instant a download is requested. */
tasks: Record;
start: (input: StartInput) => void;
+ /** Record the resolved source on an already-started task (fetched after start). */
+ setSource: (videoId: string, videoUrl: string, resolution: number) => void;
setProgress: (videoId: string, ratio: number, written: number, expected: number) => void;
setStatus: (videoId: string, status: ActiveDownloadStatus, error?: string) => void;
complete: (videoId: string) => void;
fail: (videoId: string, error: string) => void;
remove: (videoId: string) => void;
+ /**
+ * On app start: tasks persisted as in-flight were killed with the process —
+ * mark them errored (retryable, the partial file is the resume point) and
+ * drop stale cancelled ones. Must run before the orphan cleanup so their
+ * partial files are spared.
+ */
+ restoreInterrupted: () => void;
};
-export const useActiveDownloadsStore = create((set) => ({
- tasks: {},
+export const useActiveDownloadsStore = create()(
+ persist(
+ (set) => ({
+ tasks: {},
- start: (input) =>
- set((s) => ({
- tasks: {
- ...s.tasks,
- [input.videoId]: { ...input, progress: 0, status: 'preparing', startedAt: Date.now() },
- },
- })),
-
- setProgress: (videoId, ratio, written, expected) =>
- set((s) => {
- const t = s.tasks[videoId];
- if (!t) return {};
- const indeterminate = !expected || expected <= 0;
- return {
- tasks: {
- ...s.tasks,
- [videoId]: {
- ...t,
- status: 'downloading',
- progress: indeterminate ? -1 : Math.min(1, Math.max(0, ratio)),
- totalBytesWritten: written,
- totalBytesExpected: expected,
+ start: (input) =>
+ set((s) => ({
+ tasks: {
+ ...s.tasks,
+ [input.videoId]: { ...input, progress: 0, status: 'preparing', startedAt: Date.now() },
},
- },
- };
- }),
+ })),
- setStatus: (videoId, status, error) =>
- set((s) => {
- const t = s.tasks[videoId];
- if (!t) return {};
- return { tasks: { ...s.tasks, [videoId]: { ...t, status, error } } };
- }),
+ setSource: (videoId, videoUrl, resolution) =>
+ set((s) => {
+ const t = s.tasks[videoId];
+ if (!t) return {};
+ return { tasks: { ...s.tasks, [videoId]: { ...t, videoUrl, resolution } } };
+ }),
- complete: (videoId) =>
- set((s) => {
- const rest = { ...s.tasks };
- delete rest[videoId];
- return { tasks: rest };
- }),
+ setProgress: (videoId, ratio, written, expected) =>
+ set((s) => {
+ const t = s.tasks[videoId];
+ if (!t) return {};
+ const indeterminate = !expected || expected <= 0;
+ return {
+ tasks: {
+ ...s.tasks,
+ [videoId]: {
+ ...t,
+ status: 'downloading',
+ progress: indeterminate ? -1 : Math.min(1, Math.max(0, ratio)),
+ totalBytesWritten: written,
+ totalBytesExpected: expected,
+ },
+ },
+ };
+ }),
- fail: (videoId, error) =>
- set((s) => {
- const t = s.tasks[videoId];
- if (!t) return {};
- return { tasks: { ...s.tasks, [videoId]: { ...t, status: 'error', error } } };
- }),
+ setStatus: (videoId, status, error) =>
+ set((s) => {
+ const t = s.tasks[videoId];
+ if (!t) return {};
+ return { tasks: { ...s.tasks, [videoId]: { ...t, status, error } } };
+ }),
+
+ complete: (videoId) =>
+ set((s) => {
+ const rest = { ...s.tasks };
+ delete rest[videoId];
+ return { tasks: rest };
+ }),
+
+ fail: (videoId, error) =>
+ set((s) => {
+ const t = s.tasks[videoId];
+ if (!t) return {};
+ return { tasks: { ...s.tasks, [videoId]: { ...t, status: 'error', error } } };
+ }),
+
+ remove: (videoId) =>
+ set((s) => {
+ const rest = { ...s.tasks };
+ delete rest[videoId];
+ return { tasks: rest };
+ }),
- remove: (videoId) =>
- set((s) => {
- const rest = { ...s.tasks };
- delete rest[videoId];
- return { tasks: rest };
+ restoreInterrupted: () =>
+ set((s) => {
+ const tasks: Record = {};
+ for (const [id, task] of Object.entries(s.tasks)) {
+ if (task.status === 'cancelled') continue; // user already gave up
+ tasks[id] =
+ task.status === 'error' || task.status === 'paused'
+ ? task // already actionable (retry / resume) — keep as-is
+ : { ...task, status: 'error', error: 'interrupted (app restart)' };
+ }
+ return { tasks };
+ }),
}),
-}));
+ {
+ // Persist the task table so a half-downloaded file survives an app
+ // restart as a retryable (resumable) task instead of being orphaned.
+ name: 'active-downloads',
+ storage: createJSONStorage(() => mmkvStateStorage),
+ partialize: (s) => ({ tasks: s.tasks }),
+ }
+ )
+);
/** Subscribe to a single video's active download (undefined when none). */
export function useActiveDownload(videoId: string): ActiveDownload | undefined {
diff --git a/src/lib/stores/downloaded-store.ts b/src/lib/stores/downloaded-store.ts
index 4f3c751..43aa487 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 { create } from 'zustand';
+import { useActiveDownloadsStore } from './active-downloads-store';
type DownloadedStore = {
downloads: DownloadMetadata[];
@@ -26,8 +27,12 @@ export const useDownloadedStore = create((set, get) => ({
loaded: false,
hydrate: async () => {
- // Remove orphaned on-disk files (no metadata) before reading the map.
- 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).
+ useActiveDownloadsStore.getState().restoreInterrupted();
+ const activeIds = new Set(Object.keys(useActiveDownloadsStore.getState().tasks));
+ await reconcileDownloads(activeIds);
const downloads = getAllDownloads();
set({
downloads,
diff --git a/src/translations/en.json b/src/translations/en.json
index 1e590e1..b0516a4 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -3,6 +3,8 @@
"appName": "HAnime1",
"loading": "Loading…",
"retry": "Retry",
+ "pause": "Pause",
+ "resume": "Resume",
"error": "Something went wrong",
"empty": "Nothing here yet",
"noResults": "No results found",
@@ -46,6 +48,7 @@
"removeHistory": "Remove from history",
"downloading": "Downloading",
"failed": "Failed",
+ "paused": "Paused",
"cancelled": "Cancelled",
"deleteDownloadTitle": "Delete download",
"deleteDownloadMsg": "Remove \"{{title}}\" from downloads?"
@@ -58,8 +61,7 @@
"views": "views",
"uploaded": "Uploaded",
"tags": "Tags",
- "play": "Play",
- "retry": "Tap to retry"
+ "play": "Play"
},
"settings": {
"title": "Settings",
diff --git a/src/translations/ja.json b/src/translations/ja.json
index e16d5f9..742aa36 100644
--- a/src/translations/ja.json
+++ b/src/translations/ja.json
@@ -3,6 +3,8 @@
"appName": "HAnime1",
"loading": "読み込み中…",
"retry": "再試行",
+ "pause": "一時停止",
+ "resume": "再開",
"error": "エラーが発生しました",
"empty": "まだありません",
"noResults": "結果が見つかりません",
@@ -46,6 +48,7 @@
"removeHistory": "履歴から削除",
"downloading": "ダウンロード中",
"failed": "失敗",
+ "paused": "一時停止中",
"cancelled": "キャンセル済み",
"deleteDownloadTitle": "ダウンロードを削除",
"deleteDownloadMsg": "「{{title}}」をダウンロードから削除しますか?"
@@ -58,8 +61,7 @@
"views": "回視聴",
"uploaded": "投稿",
"tags": "タグ",
- "play": "再生",
- "retry": "タップして再試行"
+ "play": "再生"
},
"settings": {
"title": "設定",
diff --git a/src/translations/ko.json b/src/translations/ko.json
index 18cec68..6464f96 100644
--- a/src/translations/ko.json
+++ b/src/translations/ko.json
@@ -3,6 +3,8 @@
"appName": "HAnime1",
"loading": "불러오는 중…",
"retry": "다시 시도",
+ "pause": "일시정지",
+ "resume": "계속",
"error": "문제가 발생했습니다",
"empty": "아직 항목이 없습니다",
"noResults": "결과를 찾을 수 없습니다",
@@ -46,6 +48,7 @@
"removeHistory": "기록에서 삭제",
"downloading": "다운로드 중",
"failed": "실패",
+ "paused": "일시정지됨",
"cancelled": "취소됨",
"deleteDownloadTitle": "다운로드 삭제",
"deleteDownloadMsg": "다운로드에서 「{{title}}」을(를) 삭제하시겠습니까?"
@@ -58,8 +61,7 @@
"views": "회 시청",
"uploaded": "업로드",
"tags": "태그",
- "play": "재생",
- "retry": "탭하여 다시 시도"
+ "play": "재생"
},
"settings": {
"title": "설정",
diff --git a/src/translations/zh-CN.json b/src/translations/zh-CN.json
index f023982..1086f7d 100644
--- a/src/translations/zh-CN.json
+++ b/src/translations/zh-CN.json
@@ -3,6 +3,8 @@
"appName": "HAnime1",
"loading": "载入中…",
"retry": "重试",
+ "pause": "暂停",
+ "resume": "继续",
"error": "发生错误",
"empty": "目前没有内容",
"noResults": "找不到结果",
@@ -46,6 +48,7 @@
"removeHistory": "删除观看记录",
"downloading": "下载中",
"failed": "失败",
+ "paused": "已暂停",
"cancelled": "已取消",
"deleteDownloadTitle": "删除下载",
"deleteDownloadMsg": "从下载中移除「{{title}}」?"
@@ -58,8 +61,7 @@
"views": "次观看",
"uploaded": "上架",
"tags": "标签",
- "play": "播放",
- "retry": "点击重试"
+ "play": "播放"
},
"settings": {
"title": "设置",
diff --git a/src/translations/zh.json b/src/translations/zh.json
index 6492145..9df1e9c 100644
--- a/src/translations/zh.json
+++ b/src/translations/zh.json
@@ -3,6 +3,8 @@
"appName": "HAnime1",
"loading": "載入中…",
"retry": "重試",
+ "pause": "暫停",
+ "resume": "繼續",
"error": "發生錯誤",
"empty": "目前沒有內容",
"noResults": "找不到結果",
@@ -46,6 +48,7 @@
"removeHistory": "刪除觀看紀錄",
"downloading": "下載中",
"failed": "失敗",
+ "paused": "已暫停",
"cancelled": "已取消",
"deleteDownloadTitle": "刪除下載",
"deleteDownloadMsg": "從下載中移除「{{title}}」?"
@@ -58,8 +61,7 @@
"views": "次觀看",
"uploaded": "上架",
"tags": "標籤",
- "play": "播放",
- "retry": "點擊重試"
+ "play": "播放"
},
"settings": {
"title": "設定",