v3: localization fixes, char counters & limits on all text fields, ux fixes

This commit is contained in:
khannurien
2026-04-03 19:47:37 +00:00
parent 0ce80398a4
commit a69788c15b
48 changed files with 1133 additions and 305 deletions

View File

@@ -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)}
>