mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
The download progress toast sits in the bottom-right corner on top of the comment composer, blocking the voice-recording button and the comment box for the whole duration of a download. Render it through toast.custom so it can be collapsed to a small pill (percent + spinner) and expanded again while the download keeps running. The minimized choice sticks for the rest of the session. The sonner <li> is click-through, so only the panel itself covers the controls underneath. Also dismiss the panel on failure — it had duration: Infinity and used to stay on screen forever after an error.
162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
'use client';
|
|
|
|
// Above this size we don't buffer the file in memory to rename it — the caller
|
|
// falls back to a plain navigation so the browser streams it straight to disk
|
|
// (with the CDN's own filename). 10 GiB.
|
|
export const MAX_NAMED_DOWNLOAD_BYTES = 10 * 1024 * 1024 * 1024;
|
|
|
|
const MIME_EXTENSION_MAP: Record<string, string> = {
|
|
'video/mp4': 'mp4',
|
|
'video/webm': 'webm',
|
|
'video/quicktime': 'mov',
|
|
'video/x-matroska': 'mkv',
|
|
'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('.');
|
|
if (dot === -1) return '';
|
|
const ext = path.slice(dot + 1).toLowerCase();
|
|
return ext.length >= 1 && ext.length <= 5 ? ext : '';
|
|
}
|
|
|
|
function replaceExtension(fileName: string, ext: string): string {
|
|
const dot = fileName.lastIndexOf('.');
|
|
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
|
|
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`;
|
|
}
|
|
|
|
/** Percentage done, or null when the server didn't send a Content-Length. */
|
|
export function downloadProgressPercent(progress: DownloadProgress): number | null {
|
|
const { receivedBytes, totalBytes } = progress;
|
|
if (!totalBytes || totalBytes <= 0) return null;
|
|
return Math.min(100, Math.floor((receivedBytes / totalBytes) * 100));
|
|
}
|
|
|
|
export function downloadProgressLabel(progress: DownloadProgress): string {
|
|
const { receivedBytes, totalBytes } = progress;
|
|
const pct = downloadProgressPercent(progress);
|
|
if (pct !== null && totalBytes) {
|
|
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');
|
|
anchor.href = objectUrl;
|
|
anchor.download = fileName;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
// Revoke after the download has had a chance to start.
|
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
|
|
}
|
|
|
|
/**
|
|
* Fetch a (possibly cross-origin) file and save it under `fileName`. The
|
|
* browser's `download` attribute is ignored across origins / redirects, so for
|
|
* 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 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,
|
|
onProgress?: (progress: DownloadProgress) => void
|
|
): Promise<boolean> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, { cache: 'no-store' });
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!res.ok) return false;
|
|
|
|
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<Uint8Array<ArrayBufferLike>> | null = res.body;
|
|
if (res.body && onProgress) {
|
|
let received = 0;
|
|
let lastMarker = -1;
|
|
const counter = new TransformStream<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>({
|
|
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 = streamed ? await new Response(streamed).blob() : await res.blob();
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const mimeExt = MIME_EXTENSION_MAP[contentType];
|
|
saveBlobAs(blob, mimeExt ? replaceExtension(fileName, mimeExt) : fileName);
|
|
return true;
|
|
}
|
|
|
|
/** Plain navigation download (streams to disk; filename controlled only for
|
|
* same-origin URLs via the download attribute). */
|
|
export function navigateDownload(url: string, sameOriginFileName?: string): void {
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.rel = 'noopener';
|
|
if (sameOriginFileName && url.startsWith('/')) {
|
|
anchor.download = sameOriginFileName;
|
|
}
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
}
|