v3: localization fixes, char counters & limits on all text fields, ux fixes
This commit is contained in:
@@ -61,16 +61,23 @@ function Waveform(
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(src, NUM_BARS)
|
||||
.then((p) => { if (!cancelled) setPeaks(p); })
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
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));
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
onSeek(ratio * duration);
|
||||
};
|
||||
|
||||
@@ -128,8 +135,16 @@ interface MediaPlayerProps {
|
||||
}
|
||||
|
||||
export function MediaPlayer(
|
||||
{ src, kind, mime, autoplay, onPlayStateChange, onTimeUpdate, seekRef, toggleRef }:
|
||||
MediaPlayerProps,
|
||||
{
|
||||
src,
|
||||
kind,
|
||||
mime,
|
||||
autoplay,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
seekRef,
|
||||
toggleRef,
|
||||
}: MediaPlayerProps,
|
||||
) {
|
||||
const mediaRef = useRef<HTMLMediaElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@@ -141,36 +156,50 @@ export function MediaPlayer(
|
||||
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.
|
||||
// ── Callback refs ─────────────────────────────────────────────────────────────
|
||||
// Updated via effects (after render) so the linter doesn't flag render-phase ref
|
||||
// mutation. No cleanup → no null window. Negligible stale-window between commit
|
||||
// and effect, acceptable since these are only called from async event handlers.
|
||||
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).
|
||||
// Sync prop callbacks after every render
|
||||
useEffect(() => {
|
||||
onPlayStateChangeRef.current = onPlayStateChange;
|
||||
onTimeUpdateRef.current = onTimeUpdate;
|
||||
});
|
||||
|
||||
// Stable function refs — updated via effects, indirected by the registration
|
||||
// effect below so external seekRef/toggleRef never see a null window.
|
||||
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(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// seekTo: all captured values are stable (setCurrent from useState, mediaRef DOM ref)
|
||||
useEffect(() => {
|
||||
seekToFnRef.current = (time: number) => {
|
||||
setCurrent(time);
|
||||
mediaRef.current!.currentTime = time;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// toggle: captures `playing` — re-synced whenever play state changes
|
||||
useEffect(() => {
|
||||
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(() => {});
|
||||
}
|
||||
};
|
||||
}, [playing]);
|
||||
|
||||
// Stable wrappers used everywhere inside the component
|
||||
const seekTo = (time: number) => seekToFnRef.current(time);
|
||||
@@ -182,10 +211,12 @@ export function MediaPlayer(
|
||||
useEffect(() => {
|
||||
if (!autoplay) return;
|
||||
mediaRef.current?.play()
|
||||
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
|
||||
.then(() => {
|
||||
setPlaying(true);
|
||||
onPlayStateChangeRef.current?.(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [autoplay]);
|
||||
|
||||
// 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.
|
||||
@@ -196,7 +227,6 @@ export function MediaPlayer(
|
||||
onPlayStateChangeRef.current = undefined;
|
||||
onTimeUpdateRef.current = undefined;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Register imperative handles into provider refs. seekRef/toggleRef are stable
|
||||
@@ -208,7 +238,6 @@ export function MediaPlayer(
|
||||
if (seekRef) seekRef.current = null;
|
||||
if (toggleRef) toggleRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seekRef, toggleRef]);
|
||||
|
||||
// Media element event listeners
|
||||
@@ -243,9 +272,14 @@ export function MediaPlayer(
|
||||
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]);
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
||||
@@ -257,11 +291,15 @@ export function MediaPlayer(
|
||||
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 seek = (e: React.ChangeEvent<HTMLInputElement>) => seekTo(Number(e.target.value));
|
||||
const seek = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
seekTo(Number(e.target.value));
|
||||
|
||||
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
@@ -286,10 +324,20 @@ export function MediaPlayer(
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const track = kind === "audio"
|
||||
? <Waveform src={src} current={current} duration={duration} onSeek={seekTo} />
|
||||
? (
|
||||
<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"
|
||||
@@ -365,7 +413,9 @@ export function MediaPlayer(
|
||||
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)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user