mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: on-demand preview generation with progress messages for non-native formats
This commit is contained in:
@@ -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} · {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} · {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} · {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;
|
||||
}
|
||||
@@ -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()} ·{" "}
|
||||
{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">
|
||||
|
||||
Reference in New Issue
Block a user