From 5821f73d38c642dabb3b05b9dc595ff1c613e60f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Fri, 10 Jul 2026 22:27:53 +0700 Subject: [PATCH] feat: show live progress while downloading named files Bunny/cross-origin downloads are fetched into a blob before saving, which on large files or slow connections looked stuck (spinner only). Stream the body through a counting transform and show real byte progress in a toast: per-file percent for single downloads and file N/M + percent for bulk. - Progress is measured from Content-Length + received bytes (not estimated). - The blob is assembled by the browser from the stream (can be disk-backed), so we don't accumulate chunks in the JS heap. - Only the blob path shows a toast; same-origin (R2/S3/MinIO) and the >10 GB fallback use the browser's native download UI. --- .../[projectId]/project-content-client.tsx | 23 +++++- .../video-page/hooks/use-download-actions.ts | 28 ++++++- lib/client/download-file.ts | 78 +++++++++++++++++-- lib/client/project-download.ts | 49 ++++++++++-- 4 files changed, 157 insertions(+), 21 deletions(-) diff --git a/app/(dashboard)/projects/[projectId]/project-content-client.tsx b/app/(dashboard)/projects/[projectId]/project-content-client.tsx index a186a41..9180c4b 100644 --- a/app/(dashboard)/projects/[projectId]/project-content-client.tsx +++ b/app/(dashboard)/projects/[projectId]/project-content-client.tsx @@ -222,9 +222,26 @@ export function ProjectContentClient({ return; } - toast.info(`Starting download of ${manifest.totalFiles} files…`); - await runProjectDownloadManifest(manifest); - toast.success(`Started ${manifest.totalFiles} file downloads`); + const downloadToastId = `project-download-${projectId}`; + toast.loading(`Downloading ${manifest.totalFiles} files…`, { + id: downloadToastId, + duration: Infinity, + }); + await runProjectDownloadManifest(manifest, (p) => { + const pct = + p.totalBytes && p.totalBytes > 0 + ? ` · ${Math.min(100, Math.floor((p.receivedBytes / p.totalBytes) * 100))}%` + : ''; + toast.loading(`Downloading file ${p.index}/${p.total}`, { + id: downloadToastId, + description: `${p.fileName}${pct}`, + duration: Infinity, + }); + }); + toast.success(`Downloaded ${manifest.totalFiles} files`, { + id: downloadToastId, + duration: 4000, + }); } catch { toast.error('Failed to start project download'); } finally { diff --git a/components/video-page/hooks/use-download-actions.ts b/components/video-page/hooks/use-download-actions.ts index 8ec8af1..13683a9 100644 --- a/components/video-page/hooks/use-download-actions.ts +++ b/components/video-page/hooks/use-download-actions.ts @@ -10,7 +10,12 @@ import type { VideoData, } from '@/components/video-page/types'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; -import { downloadNamedFile, extensionFromUrl, navigateDownload } from '@/lib/client/download-file'; +import { + downloadNamedFile, + downloadProgressLabel, + extensionFromUrl, + navigateDownload, +} from '@/lib/client/download-file'; function sanitizeDownloadFileName(value: string): string { return value @@ -143,8 +148,25 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP (activeVersion.providerId === 'direct' ? extensionFromUrl(activeVersion.originalUrl) : '') || 'mp4'; - const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`); - if (!saved) { + + // The file is pulled into the browser before it can be saved, which on + // a big file / slow connection takes a while with no native download UI + // — show live progress so it doesn't look stuck. + const toastId = `download-${activeVersion.id}`; + toast.loading(`Downloading “${baseName}”…`, { id: toastId, duration: Infinity }); + const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`, (p) => { + toast.loading(`Downloading “${baseName}”`, { + id: toastId, + description: downloadProgressLabel(p), + duration: Infinity, + }); + }); + if (saved) { + toast.success(`“${baseName}” downloaded`, { id: toastId, duration: 4000 }); + } else { + // Too large to buffer (or fetch blocked): let the browser download it + // directly (its own progress UI, CDN filename). + toast.dismiss(toastId); navigateDownload(downloadUrl); } } diff --git a/lib/client/download-file.ts b/lib/client/download-file.ts index 6a07b6a..707a801 100644 --- a/lib/client/download-file.ts +++ b/lib/client/download-file.ts @@ -13,6 +13,12 @@ const MIME_EXTENSION_MAP: Record = { 'video/x-msvideo': 'avi', }; +export type DownloadProgress = { + receivedBytes: number; + /** null when the server didn't send a Content-Length. */ + totalBytes: number | null; +}; + export function extensionFromUrl(url: string): string { const path = url.split('?')[0] ?? url; const dot = path.lastIndexOf('.'); @@ -27,6 +33,22 @@ function replaceExtension(fileName: string, ext: string): string { return `${stem}.${ext}`; } +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB'; + const mb = bytes / (1024 * 1024); + if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`; + return `${(mb / 1024).toFixed(2)} GB`; +} + +export function downloadProgressLabel(progress: DownloadProgress): string { + const { receivedBytes, totalBytes } = progress; + if (totalBytes && totalBytes > 0) { + const pct = Math.min(100, Math.floor((receivedBytes / totalBytes) * 100)); + return `${pct}% · ${formatBytes(receivedBytes)} / ${formatBytes(totalBytes)}`; + } + return formatBytes(receivedBytes); +} + export function saveBlobAs(blob: Blob, fileName: string): void { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); @@ -45,12 +67,20 @@ export function saveBlobAs(blob: Blob, fileName: string): void { * CDN sources we pull the bytes (CORS is open on them) and save the blob, which * lets us control the name and derive the real extension from the content type. * + * The body is streamed through a counting transform so `onProgress` can report + * live progress; the Blob itself is assembled by the browser (which can back + * large blobs on disk) rather than accumulated in the JS heap. + * * Returns `false` (without downloading) when the file is larger than - * MAX_NAMED_DOWNLOAD_BYTES — buffering that in memory would be unsafe — or when - * the fetch isn't usable, so the caller can fall back to a plain navigation. - * Returns `true` when the named blob was saved. + * MAX_NAMED_DOWNLOAD_BYTES — buffering that would be unsafe — or when the fetch + * isn't usable, so the caller can fall back to a plain navigation. Returns + * `true` when the named blob was saved. */ -export async function downloadNamedFile(url: string, fileName: string): Promise { +export async function downloadNamedFile( + url: string, + fileName: string, + onProgress?: (progress: DownloadProgress) => void +): Promise { let res: Response; try { res = await fetch(url, { cache: 'no-store' }); @@ -59,20 +89,52 @@ export async function downloadNamedFile(url: string, fileName: string): Promise< } if (!res.ok) return false; - const contentLength = Number(res.headers.get('content-length')); - if (Number.isFinite(contentLength) && contentLength > MAX_NAMED_DOWNLOAD_BYTES) { + const contentLengthRaw = Number(res.headers.get('content-length')); + const totalBytes = + Number.isFinite(contentLengthRaw) && contentLengthRaw > 0 ? contentLengthRaw : null; + if (totalBytes !== null && totalBytes > MAX_NAMED_DOWNLOAD_BYTES) { await res.body?.cancel().catch(() => {}); return false; } + // Extension comes from the response Content-Type (a stream-built Blob has no + // type), falling back to the URL. + const contentType = (res.headers.get('content-type') || '').split(';')[0]?.trim() ?? ''; + + let streamed: ReadableStream> | null = res.body; + if (res.body && onProgress) { + let received = 0; + let lastMarker = -1; + const counter = new TransformStream, Uint8Array>({ + transform(chunk, controller) { + received += chunk.byteLength; + // Safety net for streams without a Content-Length. + if (received > MAX_NAMED_DOWNLOAD_BYTES) { + controller.error(new Error('File exceeds the in-memory download limit')); + return; + } + // Throttle: emit on each whole-percent change (or per ~2 MB if unknown). + const marker = totalBytes + ? Math.floor((received / totalBytes) * 100) + : Math.floor(received / (2 * 1024 * 1024)); + if (marker !== lastMarker) { + lastMarker = marker; + onProgress({ receivedBytes: received, totalBytes }); + } + controller.enqueue(chunk); + }, + }); + streamed = res.body.pipeThrough(counter); + } + let blob: Blob; try { - blob = await res.blob(); + blob = streamed ? await new Response(streamed).blob() : await res.blob(); } catch { return false; } - const mimeExt = MIME_EXTENSION_MAP[blob.type]; + const mimeExt = MIME_EXTENSION_MAP[contentType]; saveBlobAs(blob, mimeExt ? replaceExtension(fileName, mimeExt) : fileName); return true; } diff --git a/lib/client/project-download.ts b/lib/client/project-download.ts index c485698..5b743ca 100644 --- a/lib/client/project-download.ts +++ b/lib/client/project-download.ts @@ -4,6 +4,15 @@ import { downloadNamedFile, navigateDownload } from '@/lib/client/download-file' const DOWNLOAD_STAGGER_MS = 500; +export type ManifestDownloadProgress = { + /** 1-based index of the file currently downloading. */ + index: number; + total: number; + fileName: string; + receivedBytes: number; + totalBytes: number | null; +}; + export type ProjectDownloadManifestFile = { fileName: string; url: string; @@ -21,10 +30,14 @@ function sleep(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)); } -async function triggerBrowserDownload(file: ProjectDownloadManifestFile): Promise { +async function triggerBrowserDownload( + file: ProjectDownloadManifestFile, + onProgress?: (received: number, total: number | null) => void +): Promise { // Same-origin proxy files (R2 / S3 / MinIO via /api/upload/video/...): the // download attribute applies and the browser streams straight to disk, so the - // name is correct at any size with no memory cost. + // name is correct at any size with no memory cost (browser shows its own + // progress, so we don't track bytes here). if (file.url.startsWith('/api/upload/video/')) { navigateDownload(file.url, file.fileName); return; @@ -33,17 +46,39 @@ async function triggerBrowserDownload(file: ProjectDownloadManifestFile): Promis // Bunny (CDN redirect) and external direct hosts are cross-origin, so the name // only applies if we fetch the bytes. downloadNamedFile does that for files up // to 10 GB; larger ones fall back to a plain navigation (CDN filename). - const saved = await downloadNamedFile(file.url, file.fileName); + const saved = await downloadNamedFile(file.url, file.fileName, (p) => + onProgress?.(p.receivedBytes, p.totalBytes) + ); if (!saved) { navigateDownload(file.url, file.fileName); } } -export async function runProjectDownloadManifest(manifest: ProjectDownloadManifest): Promise { - for (let index = 0; index < manifest.files.length; index += 1) { +export async function runProjectDownloadManifest( + manifest: ProjectDownloadManifest, + onProgress?: (progress: ManifestDownloadProgress) => void +): Promise { + const total = manifest.files.length; + for (let index = 0; index < total; index += 1) { + const file = manifest.files[index]!; + onProgress?.({ + index: index + 1, + total, + fileName: file.fileName, + receivedBytes: 0, + totalBytes: file.sizeBytes, + }); // Sequential so at most one file is buffered in memory at a time. - await triggerBrowserDownload(manifest.files[index]!); - if (index < manifest.files.length - 1) { + await triggerBrowserDownload(file, (received, fileTotal) => + onProgress?.({ + index: index + 1, + total, + fileName: file.fileName, + receivedBytes: received, + totalBytes: fileTotal, + }) + ); + if (index < total - 1) { await sleep(DOWNLOAD_STAGGER_MS); } }