265 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|