v3: added localization, use global player for uploaded audio/video files

This commit is contained in:
khannurien
2026-04-03 15:29:33 +00:00
parent 378b3ffa46
commit 0ce80398a4
64 changed files with 4248 additions and 941 deletions

View File

@@ -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} />;
}