feat: on-demand preview generation with progress messages for non-native formats

This commit is contained in:
SnapOtter
2026-06-15 12:04:30 +08:00
parent 511e941da0
commit d3cd4e41b5
24 changed files with 407 additions and 17 deletions
@@ -0,0 +1,196 @@
import { Play, RefreshCw, Video, Volume2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatFileSize } from "@/lib/download";
import { cn } from "@/lib/utils";
const PROGRESS_MESSAGES = [
"Warming up the otter...",
"Crunching pixels...",
"Teaching the codec...",
"Almost there...",
"Brewing the preview...",
"Convincing the frames...",
"Polishing the output...",
"Just a moment...",
];
type PreviewState = "idle" | "generating" | "ready" | "error";
export interface NonNativePreviewProps {
file: File;
filename: string;
fileSize: number;
modality: "video" | "audio";
}
export function NonNativePreview({ file, filename, fileSize, modality }: NonNativePreviewProps) {
const { t } = useTranslation();
const [state, setState] = useState<PreviewState>("idle");
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [messageIndex, setMessageIndex] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Clean up blob URL on unmount
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
if (intervalRef.current) clearInterval(intervalRef.current);
abortRef.current?.abort();
};
}, [previewUrl]);
const startMessageRotation = useCallback(() => {
setMessageIndex(0);
intervalRef.current = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % PROGRESS_MESSAGES.length);
}, 2500);
}, []);
const stopMessageRotation = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
const generatePreview = useCallback(async () => {
setState("generating");
startMessageRotation();
const controller = new AbortController();
abortRef.current = controller;
try {
const formData = new FormData();
formData.append("file", file, filename);
const response = await fetch("/api/v1/preview/generate", {
method: "POST",
body: formData,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Preview generation failed: ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// Revoke previous URL if any
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(url);
setState("ready");
} catch (err) {
if ((err as Error).name !== "AbortError") {
setState("error");
}
} finally {
stopMessageRotation();
}
}, [file, filename, previewUrl, startMessageRotation, stopMessageRotation]);
const ext = filename.split(".").pop()?.toUpperCase() ?? "";
const IconComponent = modality === "audio" ? Volume2 : Video;
// Idle state: file info + generate button
if (state === "idle") {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<IconComponent className="h-8 w-8 text-muted-foreground" />
</div>
<p className="font-medium text-foreground mb-1">{filename}</p>
<p className="text-sm text-muted-foreground mb-3">
{ext} &middot; {formatFileSize(fileSize)}
</p>
<button
type="button"
onClick={generatePreview}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
>
<Play className="h-4 w-4" />
{t.toolPage.generatePreview}
</button>
</div>
</div>
);
}
// Generating state: progress bar + rotating messages
if (state === "generating") {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs w-full">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<IconComponent className="h-8 w-8 text-muted-foreground" />
</div>
<p className="font-medium text-foreground mb-1">{filename}</p>
<p className="text-sm text-muted-foreground mb-4">
{ext} &middot; {formatFileSize(fileSize)}
</p>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden mb-3">
<div
className={cn(
"h-full w-1/4 bg-primary rounded-full",
"animate-[shimmer_1.5s_ease-in-out_infinite]",
)}
/>
</div>
<p className="text-sm text-muted-foreground">{PROGRESS_MESSAGES[messageIndex]}</p>
</div>
</div>
);
}
// Error state: retry button
if (state === "error") {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<IconComponent className="h-8 w-8 text-muted-foreground" />
</div>
<p className="font-medium text-foreground mb-1">{t.toolPage.previewFailed}</p>
<p className="text-sm text-muted-foreground mb-3">
{filename} &middot; {formatFileSize(fileSize)}
</p>
<button
type="button"
onClick={generatePreview}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
>
<RefreshCw className="h-4 w-4" />
{t.common.retry}
</button>
</div>
</div>
);
}
// Ready state: show the player
if (state === "ready" && previewUrl) {
if (modality === "audio") {
return (
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
{/* biome-ignore lint/a11y/useMediaCaption: preview audio player */}
<audio controls className="w-full" src={previewUrl} />
</div>
</div>
);
}
return (
<div className="flex-1 flex items-center justify-center p-2">
{/* biome-ignore lint/a11y/useMediaCaption: preview video player */}
<video controls className="max-h-full max-w-full rounded-md" src={previewUrl} />
</div>
);
}
return null;
}
+28 -15
View File
@@ -10,7 +10,6 @@ import {
FileImage,
Loader2,
Upload,
Video,
XCircle,
} from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -61,6 +60,11 @@ const WaveformPlayer = lazy(() =>
const DocumentView = lazy(() =>
import("@/components/tools/document-view").then((m) => ({ default: m.DocumentView })),
);
const NonNativePreview = lazy(() =>
import("@/components/common/non-native-preview").then((m) => ({
default: m.NonNativePreview,
})),
);
/** Formats that browsers can render in <img> tags. */
const BROWSER_PREVIEWABLE_EXTS = new Set([
@@ -668,21 +672,16 @@ export function ToolPage() {
const nativeVideoExts = new Set(["mp4", "webm", "ogg", "ogv", "m4v", "mov"]);
const isNativeVideo = nativeVideoExts.has(currentExt);
if (!isNativeVideo && currentExt) {
if (!isNativeVideo && currentExt && currentEntry?.file) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<Video className="h-8 w-8 text-muted-foreground" />
</div>
<p className="font-medium text-foreground mb-1">{currentFileName}</p>
<p className="text-sm text-muted-foreground mb-1">
{currentExt.toUpperCase()} &middot;{" "}
{currentEntry?.file ? formatFileSize(currentEntry.file.size) : ""}
</p>
<p className="text-xs text-muted-foreground/60">{t.toolPage.previewUnavailable}</p>
</div>
</div>
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
<NonNativePreview
file={currentEntry.file}
filename={currentFileName}
fileSize={currentEntry.file.size}
modality="video"
/>
</Suspense>
);
}
return (
@@ -901,6 +900,20 @@ export function ToolPage() {
const fsize = selectedFileSize ?? files[0].size;
if (!canBrowserPreview(originalBlobUrl, fname)) {
const ext = fname.split(".").pop()?.toUpperCase() ?? "";
const previewModality =
tool?.modality === "video" ? "video" : tool?.modality === "audio" ? "audio" : null;
if (previewModality && currentEntry?.file) {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
<NonNativePreview
file={currentEntry.file}
filename={fname}
fileSize={fsize}
modality={previewModality}
/>
</Suspense>
);
}
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs">