v3: added localization, use global player for uploaded audio/video files
This commit is contained in:
@@ -1,11 +1,119 @@
|
||||
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 { 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
|
||||
? (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
)
|
||||
: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
)}
|
||||
</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 mimeIcon(mime: string): string {
|
||||
@@ -17,10 +125,13 @@ function mimeIcon(mime: string): string {
|
||||
}
|
||||
|
||||
export default function FilePreview(
|
||||
{ dump, compact = false }: FilePreviewProps,
|
||||
{ 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 isMedia = mime.startsWith("video/") || mime.startsWith("audio/");
|
||||
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
|
||||
|
||||
if (compact) {
|
||||
if (mime.startsWith("image/")) {
|
||||
@@ -35,6 +146,45 @@ export default function FilePreview(
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
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 });
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={fileUrl}
|
||||
preload="metadata"
|
||||
className="rich-content-compact-thumbnail"
|
||||
muted
|
||||
onLoadedMetadata={(e) => {
|
||||
(e.target as HTMLVideoElement).currentTime = 0.1;
|
||||
}}
|
||||
/>
|
||||
<span className="rich-content-play-overlay">▶</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-compact-icon rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
|
||||
}}
|
||||
>
|
||||
{mimeIcon(mime)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
|
||||
}
|
||||
|
||||
@@ -45,10 +195,37 @@ export default function FilePreview(
|
||||
}
|
||||
|
||||
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="metadata"
|
||||
className="file-preview-video-thumb"
|
||||
muted
|
||||
onLoadedMetadata={(e) => {
|
||||
(e.target as HTMLVideoElement).currentTime = 0.1;
|
||||
}}
|
||||
/>
|
||||
<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} />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user