Files
gerbeur/src/components/FilePreview.tsx

265 lines
7.5 KiB
TypeScript

import { useContext, useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { formatBytes } from "../utils/format.ts";
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts";
import {
BAR_GAP,
BAR_W,
extractPeaks,
NUM_BARS,
VIEWBOX_W,
WAVEFORM_H,
} from "../utils/waveform.ts";
interface FilePreviewProps {
dump: Dump;
compact?: boolean;
global?: boolean;
}
// Waveform preview for the dump detail page — routes to global player,
// reflects live play state and position from PlayerContext.
function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) {
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
useContext(PlayerContext);
const [peaks, setPeaks] = useState<Float32Array | null>(null);
const isActive = current?.kind === "file" && current.fileUrl === fileUrl;
const progress = isActive && duration > 0 ? currentTime / duration : 0;
useEffect(() => {
let cancelled = false;
extractPeaks(fileUrl, NUM_BARS)
.then((p) => {
if (!cancelled) setPeaks(p);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [fileUrl]);
const handlePlayBtn = () => {
if (isActive) togglePlay();
else play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
};
const handleWaveformClick = (e: React.MouseEvent<Element>) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width),
);
if (isActive) {
seekTo(ratio * duration);
} else {
// Start playing and seek once it loads — seekTo after play() is a no-op
// until MediaPlayer mounts; the fraction is best-effort on first click
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}
};
const isPlaying = isActive && playing;
return (
<div
className={`audio-file-preview${
isActive ? " audio-file-preview--active" : ""
}`}
>
<button
type="button"
className="audio-player-btn"
onClick={handlePlayBtn}
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? <IconPause /> : <IconPlay />}
</button>
{peaks
? (
<svg
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
preserveAspectRatio="none"
className="waveform-svg"
onClick={handleWaveformClick}
>
{Array.from(peaks).map((p, i) => {
const barH = Math.max(p * WAVEFORM_H, 2);
const x = i * (BAR_W + BAR_GAP);
const y = (WAVEFORM_H - barH) / 2;
const played = i / NUM_BARS <= progress;
return (
<rect
key={i}
x={x}
y={y}
width={BAR_W}
height={barH}
className={`waveform-bar${
played ? " waveform-bar--played" : ""
}`}
/>
);
})}
</svg>
)
: (
<div className="waveform-skeleton" onClick={handleWaveformClick}>
<div
className="waveform-skeleton-fill"
style={{ width: `${progress * 100}%` }}
/>
</div>
)}
</div>
);
}
function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
const [failed, setFailed] = useState(false);
if (failed) {
return <span className="rich-content-compact-icon">{fallback}</span>;
}
return (
<img
src={src}
alt=""
className="rich-content-compact-thumbnail"
onError={() => setFailed(true)}
/>
);
}
function mimeIcon(mime: string): string {
if (mime.startsWith("video/")) return "🎬";
if (mime.startsWith("audio/")) return "🎵";
if (mime === "application/pdf") return "📄";
if (mime.startsWith("text/")) return "📝";
return "📁";
}
export default function FilePreview(
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
) {
const { current, playing, play, togglePlay } = useContext(PlayerContext);
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
const mime = dump.fileMime ?? "";
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
if (compact) {
if (mime.startsWith("image/")) {
return (
<img
src={fileUrl}
alt={dump.fileName}
className="rich-content-compact-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
);
}
if (mime.startsWith("video/")) {
const thumbUrl = `${API_URL}/api/thumbnails/${dump.id}`;
return (
<button
type="button"
className={`rich-content-thumbnail-btn${
isPlaying ? " is-playing" : ""
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}}
>
<VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
<span className="rich-content-play-overlay"></span>
</button>
);
}
if (mime.startsWith("audio/")) {
return (
<button
type="button"
className={`rich-content-thumbnail-btn${
isPlaying ? " is-playing" : ""
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}}
>
<span className="rich-content-compact-icon">{mimeIcon(mime)}</span>
</button>
);
}
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
}
if (mime.startsWith("image/")) {
return (
<img src={fileUrl} alt={dump.fileName} className="file-preview-image" />
);
}
if (mime.startsWith("video/")) {
if (useGlobal) {
const videoActive = isPlaying;
const videoPlaying = videoActive && playing;
return (
<button
type="button"
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
onClick={() =>
videoActive ? togglePlay() : play({
kind: "file",
fileUrl,
mimeType: mime,
title: dump.title,
})}
>
<video
src={fileUrl}
preload="none"
className="file-preview-video-thumb"
muted
/>
<span className="rich-content-play-overlay">
{videoPlaying ? "⏸" : "▶"}
</span>
</button>
);
}
return <MediaPlayer src={fileUrl} kind="video" mime={mime} />;
}
if (mime.startsWith("audio/")) {
if (useGlobal) {
return <AudioFilePreview fileUrl={fileUrl} mime={mime} dump={dump} />;
}
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
}
if (mime === "application/pdf") {
return (
<embed
src={fileUrl}
type="application/pdf"
className="file-preview-pdf"
/>
);
}
return (
<a href={fileUrl} download={dump.fileName} className="file-download-link">
{mimeIcon(mime)} Download {dump.fileName}
{dump.fileSize != null && ` (${formatBytes(dump.fileSize)})`}
</a>
);
}