feat: modality-aware file preview in Files library

- Video files: inline video player with controls
- Audio files: waveform visualization with playback (wavesurfer.js)
- Image files: thumbnail preview (existing behavior)
- Documents/data: file type icon with format label
This commit is contained in:
SnapOtter
2026-06-15 11:14:51 +08:00
parent 3a5d55cf26
commit a70b13ad32
+80 -8
View File
@@ -1,6 +1,6 @@
import { TOOLS } from "@snapotter/shared"; import { TOOLS } from "@snapotter/shared";
import { FileImage, ImageIcon } from "lucide-react"; import { FileImage, FileText, ImageIcon, Music, Video } from "lucide-react";
import { useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { import {
@@ -14,6 +14,10 @@ import { cn } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { useFilesPageStore } from "@/stores/files-page-store"; import { useFilesPageStore } from "@/stores/files-page-store";
const WaveformPlayer = lazy(() =>
import("@/components/common/waveform-player").then((m) => ({ default: m.WaveformPlayer })),
);
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) { function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [blobUrl, setBlobUrl] = useState<string | null>(null); const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -60,6 +64,78 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
return <img src={blobUrl} alt={alt} className={className} />; return <img src={blobUrl} alt={alt} className={className} />;
} }
function FilePreview({
fileId,
mimeType,
name,
}: {
fileId: string;
mimeType: string;
name: string;
}) {
const downloadUrl = getFileDownloadUrl(fileId);
const headers = formatHeaders();
const [mediaSrc, setMediaSrc] = useState<string | null>(null);
useEffect(() => {
if (!mimeType.startsWith("video/") && !mimeType.startsWith("audio/")) return;
let revoked = false;
fetch(downloadUrl, { headers })
.then((res) => res.blob())
.then((blob) => {
if (!revoked) setMediaSrc(URL.createObjectURL(blob));
})
.catch(() => {});
return () => {
revoked = true;
if (mediaSrc) URL.revokeObjectURL(mediaSrc);
};
}, [fileId]);
if (mimeType.startsWith("video/")) {
return mediaSrc ? (
<video src={mediaSrc} controls className="w-full rounded-lg max-h-48 bg-black">
<track kind="captions" />
</video>
) : (
<div className="w-full h-32 rounded-lg bg-muted flex items-center justify-center">
<Video className="h-8 w-8 text-muted-foreground animate-pulse" />
</div>
);
}
if (mimeType.startsWith("audio/")) {
return mediaSrc ? (
<Suspense fallback={<div className="w-full h-24 rounded-lg bg-muted animate-pulse" />}>
<WaveformPlayer src={mediaSrc} className="w-full" />
</Suspense>
) : (
<div className="w-full h-24 rounded-lg bg-muted flex items-center justify-center">
<Music className="h-8 w-8 text-muted-foreground animate-pulse" />
</div>
);
}
if (mimeType.startsWith("image/")) {
return (
<AuthImage
src={getFileThumbnailUrl(fileId)}
alt={name}
className="w-full rounded-lg object-contain max-h-48 bg-muted"
/>
);
}
return (
<div className="w-full h-32 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
<FileText className="h-8 w-8 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{mimeType.split("/").pop()?.toUpperCase()}
</span>
</div>
);
}
function toolName(toolId: string): string { function toolName(toolId: string): string {
return TOOLS.find((t) => t.id === toolId)?.name ?? toolId; return TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
} }
@@ -184,13 +260,9 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
: "w-60 border-s border-border p-4 shrink-0 hidden lg:flex flex-col", : "w-60 border-s border-border p-4 shrink-0 hidden lg:flex flex-col",
)} )}
> >
{/* Thumbnail */} {/* Preview */}
<div className={cn("border-b border-border", mobile ? "" : "pb-4")}> <div className={cn("border-b border-border", mobile ? "" : "pb-4")}>
<AuthImage <FilePreview fileId={details.id} mimeType={details.mimeType} name={details.originalName} />
src={getFileThumbnailUrl(details.id)}
alt={details.originalName}
className="w-full rounded-lg object-contain max-h-48 bg-muted"
/>
</div> </div>
{/* Details card */} {/* Details card */}