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,4 +1,12 @@
import { useEffect, useRef, useState } from "react";
import {
BAR_GAP,
BAR_W,
extractPeaks,
NUM_BARS,
VIEWBOX_W,
WAVEFORM_H,
} from "../utils/waveform.ts";
function fmt(s: number): string {
if (!isFinite(s)) return "0:00";
@@ -38,15 +46,91 @@ const IconFullscreen = () => (
</svg>
);
// ── Waveform ──────────────────────────────────────────────────────────────────
function Waveform(
{ src, current, duration, onSeek }: {
src: string;
current: number;
duration: number;
onSeek: (t: number) => void;
},
) {
const [peaks, setPeaks] = useState<Float32Array | null>(null);
useEffect(() => {
let cancelled = false;
extractPeaks(src, NUM_BARS)
.then((p) => { if (!cancelled) setPeaks(p); })
.catch(() => {});
return () => { cancelled = true; };
}, [src]);
const progress = duration > 0 ? current / duration : 0;
const handleClick = (e: React.MouseEvent<Element>) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onSeek(ratio * duration);
};
if (!peaks) {
return (
<div className="waveform-skeleton" onClick={handleClick}>
<div
className="waveform-skeleton-fill"
style={{ width: `${progress * 100}%` }}
/>
</div>
);
}
return (
<svg
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
preserveAspectRatio="none"
className="waveform-svg"
onClick={handleClick}
>
{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>
);
}
// ── MediaPlayer ───────────────────────────────────────────────────────────────
const HIDE_DELAY = 2500;
interface MediaPlayerProps {
src: string;
kind: "audio" | "video";
mime?: string;
autoplay?: boolean;
onPlayStateChange?: (playing: boolean) => void;
onTimeUpdate?: (time: number, duration: number) => void;
seekRef?: { current: ((t: number) => void) | null };
toggleRef?: { current: (() => void) | null };
}
export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
export function MediaPlayer(
{ src, kind, mime, autoplay, onPlayStateChange, onTimeUpdate, seekRef, toggleRef }:
MediaPlayerProps,
) {
const mediaRef = useRef<HTMLMediaElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
@@ -57,13 +141,93 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
const [controlsVisible, setControlsVisible] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── Callback refs (mutated in render, never trigger effects as deps) ─────────
// Updating refs in render is safe: they're only read in event handlers / effects,
// never during render. This avoids the stale-closure problem without extra effects.
const onPlayStateChangeRef = useRef(onPlayStateChange);
const onTimeUpdateRef = useRef(onTimeUpdate);
onPlayStateChangeRef.current = onPlayStateChange;
onTimeUpdateRef.current = onTimeUpdate;
// Stable function refs — logic updated in render, registered once via a stable lambda.
// This avoids the "no-deps effect" anti-pattern that created brief null windows on
// every re-render (timeupdate fires 4×/s → ref nulled & re-registered each time).
const seekToFnRef = useRef((_t: number) => {});
seekToFnRef.current = (time: number) => {
setCurrent(time);
mediaRef.current!.currentTime = time;
};
const toggleFnRef = useRef(() => {});
toggleFnRef.current = () => {
const a = mediaRef.current!;
if (playing) {
a.pause();
setPlaying(false);
onPlayStateChangeRef.current?.(false);
} else {
a.play()
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
.catch(() => {});
}
};
// Stable wrappers used everywhere inside the component
const seekTo = (time: number) => seekToFnRef.current(time);
const toggle = () => toggleFnRef.current();
// ── Effects ──────────────────────────────────────────────────────────────────
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
useEffect(() => {
if (!autoplay) return;
mediaRef.current?.play()
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// On unmount: pause and cut callbacks so stale timeupdate/ended events that fire
// between React's commit and the listener-removal effect can't reach the provider.
useEffect(() => {
const a = mediaRef.current!;
return () => {
a.pause();
onPlayStateChangeRef.current = undefined;
onTimeUpdateRef.current = undefined;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Register imperative handles into provider refs. seekRef/toggleRef are stable
// (created with useRef in PlayerProvider), so this effect runs exactly once.
useEffect(() => {
if (seekRef) seekRef.current = (t) => seekToFnRef.current(t);
if (toggleRef) toggleRef.current = () => toggleFnRef.current();
return () => {
if (seekRef) seekRef.current = null;
if (toggleRef) toggleRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seekRef, toggleRef]);
// Media element event listeners
useEffect(() => {
const a = mediaRef.current!;
const onTime = () => {
if (!dragging) setCurrent(a.currentTime);
if (!dragging) {
setCurrent(a.currentTime);
onTimeUpdateRef.current?.(a.currentTime, a.duration);
}
};
const onDuration = () => {
setDuration(a.duration);
onTimeUpdateRef.current?.(a.currentTime, a.duration);
};
const onEnded = () => {
setPlaying(false);
onPlayStateChangeRef.current?.(false);
};
const onDuration = () => setDuration(a.duration);
const onEnded = () => setPlaying(false);
a.addEventListener("timeupdate", onTime);
a.addEventListener("durationchange", onDuration);
a.addEventListener("ended", onEnded);
@@ -74,30 +238,18 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
};
}, [dragging]);
// Stop playback on unmount; the browser aborts network requests when the element leaves the DOM.
useEffect(() => {
const a = mediaRef.current!;
return () => {
a.pause();
};
}, []);
// Schedule controls hide when playing; controls are always visible when paused (derived below)
// Hide video controls after inactivity
useEffect(() => {
if (kind !== "video") return;
if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(
() => setControlsVisible(false),
HIDE_DELAY,
);
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
}
return () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
};
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
}, [playing, kind]);
// Controls are always visible when paused or for audio; otherwise follow controlsVisible state
// ── Render helpers ────────────────────────────────────────────────────────────
const showingControls = kind !== "video" || !playing || controlsVisible;
const showControlsTemporarily = () => {
@@ -105,26 +257,11 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
setControlsVisible(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(
() => setControlsVisible(false),
HIDE_DELAY,
);
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
}
};
const toggle = () => {
const a = mediaRef.current!;
if (playing) {
a.pause();
setPlaying(false);
} else a.play().then(() => setPlaying(true)).catch(() => {});
};
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
setCurrent(v);
mediaRef.current!.currentTime = v;
};
const seek = (e: React.ChangeEvent<HTMLInputElement>) => seekTo(Number(e.target.value));
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
@@ -148,24 +285,11 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
const progress = duration > 0 ? current / duration : 0;
const controls = (
<>
<button
type="button"
className="audio-player-btn"
onClick={toggle}
aria-label={playing ? "Pause" : "Play"}
>
{playing ? <IconPause /> : <IconPlay />}
</button>
<span className="audio-player-time">{fmt(current)}</span>
const track = kind === "audio"
? <Waveform src={src} current={current} duration={duration} onSeek={seekTo} />
: (
<div className="audio-player-track">
<div
className="audio-player-fill"
style={{ width: `${progress * 100}%` }}
/>
<div className="audio-player-fill" style={{ width: `${progress * 100}%` }} />
<input
type="range"
className="audio-player-range"
@@ -179,6 +303,22 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
aria-label="Seek"
/>
</div>
);
const controls = (
<>
<button
type="button"
className="audio-player-btn"
onClick={toggle}
aria-label={playing ? "Pause" : "Play"}
>
{playing ? <IconPause /> : <IconPlay />}
</button>
<span className="audio-player-time">{fmt(current)}</span>
{track}
<span className="audio-player-time">{fmt(duration)}</span>
@@ -225,9 +365,7 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
if (kind === "video") {
return (
<div
className={`video-player${
showingControls ? " video-player--controls-visible" : ""
}`}
className={`video-player${showingControls ? " video-player--controls-visible" : ""}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => playing && setControlsVisible(false)}
>