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": "設定",