v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:30:28 +00:00
parent de5abeceb2
commit 07d0344be7
131 changed files with 3893 additions and 3596 deletions

View File

@@ -0,0 +1,394 @@
import { useEffect, useRef, useState } from "react";
import { fmt } from "../../utils/duration.ts";
import { t } from "@lingui/core/macro";
import { IconFullscreen, IconPause, IconPlay, IconVolume } from "./icons.tsx";
import { SeekBar } from "./SeekBar.tsx";
import { Waveform } from "./Waveform.tsx";
import { rememberVolume, storedVolume } from "./volume.ts";
const HIDE_DELAY = 2500;
interface MediaPlayerProps {
src: string;
kind: "audio" | "video";
mime?: string;
poster?: string;
autoplay?: boolean;
startTime?: number;
onPlayStateChange?: (playing: boolean) => void;
onTimeUpdate?: (time: number, duration: number) => void;
/** Fired when the media reaches its end — the queue's advance hook. */
onEnded?: () => void;
/** Fired when the element fails to load or decode. For expiring stream URLs
* this is the signal to re-resolve. */
onError?: () => void;
/** "waveform" (default) decodes peaks from the source; "progress" draws a
* slim seek bar and never fetches the media a second time. */
trackStyle?: "waveform" | "progress";
seekRef?: { current: ((t: number) => void) | null };
toggleRef?: { current: (() => void) | null };
}
export function MediaPlayer(
{
src,
kind,
mime,
poster,
autoplay,
startTime,
onPlayStateChange,
onTimeUpdate,
onEnded,
onError,
trackStyle = "waveform",
seekRef,
toggleRef,
}: MediaPlayerProps,
) {
const mediaRef = useRef<HTMLMediaElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [dragging, setDragging] = useState(false);
const [volume, setVolume] = useState(() => storedVolume().volume);
const [muted, setMuted] = useState(() => storedVolume().muted);
const [controlsVisible, setControlsVisible] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── 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);
const onEndedRef = useRef(onEnded);
const onErrorRef = useRef(onError);
// Sync prop callbacks after every render
useEffect(() => {
onPlayStateChangeRef.current = onPlayStateChange;
onTimeUpdateRef.current = onTimeUpdate;
onEndedRef.current = onEnded;
onErrorRef.current = onError;
});
// 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) => {});
const toggleFnRef = useRef(() => {});
// 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);
const toggle = () => toggleFnRef.current();
// ── Effects ──────────────────────────────────────────────────────────────────
// Resume to a restored offset once metadata (and thus seekability) is available.
// Runs only on mount for the initial offset — later seeks go through seekTo.
useEffect(() => {
if (!startTime) return;
const a = mediaRef.current!;
const apply = () => {
a.currentTime = startTime;
setCurrent(startTime);
};
if (a.readyState >= 1) apply();
else a.addEventListener("loadedmetadata", apply, { once: true });
return () => a.removeEventListener("loadedmetadata", apply);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Apply the remembered volume to the freshly-mounted element, which always
// starts at 1.0 regardless of what the last track was playing at.
useEffect(() => {
const a = mediaRef.current;
if (!a) return;
a.volume = volume;
a.muted = muted;
// Mount only: later changes go through changeVolume/toggleMute.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
useEffect(() => {
if (!autoplay) return;
mediaRef.current?.play()
.then(() => {
setPlaying(true);
onPlayStateChangeRef.current?.(true);
})
.catch(() => {});
}, [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.
useEffect(() => {
const a = mediaRef.current!;
return () => {
a.pause();
onPlayStateChangeRef.current = undefined;
onTimeUpdateRef.current = undefined;
onEndedRef.current = undefined;
onErrorRef.current = undefined;
};
}, []);
// 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;
};
}, [seekRef, toggleRef]);
// Media element event listeners
useEffect(() => {
const a = mediaRef.current!;
const onTime = () => {
if (!dragging) {
setCurrent(a.currentTime);
onTimeUpdateRef.current?.(a.currentTime, a.duration);
}
};
const onDuration = () => {
setDuration(a.duration);
onTimeUpdateRef.current?.(a.currentTime, a.duration);
};
const onEndedEvent = () => {
setPlaying(false);
onPlayStateChangeRef.current?.(false);
onEndedRef.current?.();
};
const onErrorEvent = () => {
setPlaying(false);
onErrorRef.current?.();
};
// The element can also be started or stopped from outside the page — a
// media key, a headset, the OS's media controls — so follow it rather
// than only what our own button did.
const onPlayEvent = () => {
setPlaying(true);
onPlayStateChangeRef.current?.(true);
};
const onPauseEvent = () => {
setPlaying(false);
onPlayStateChangeRef.current?.(false);
};
a.addEventListener("timeupdate", onTime);
a.addEventListener("play", onPlayEvent);
a.addEventListener("pause", onPauseEvent);
a.addEventListener("durationchange", onDuration);
a.addEventListener("ended", onEndedEvent);
a.addEventListener("error", onErrorEvent);
return () => {
a.removeEventListener("timeupdate", onTime);
a.removeEventListener("play", onPlayEvent);
a.removeEventListener("pause", onPauseEvent);
a.removeEventListener("durationchange", onDuration);
a.removeEventListener("ended", onEndedEvent);
a.removeEventListener("error", onErrorEvent);
};
}, [dragging]);
// 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,
);
}
return () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
};
}, [playing, kind]);
// ── Render helpers ────────────────────────────────────────────────────────────
const showingControls = kind !== "video" || !playing || controlsVisible;
const showControlsTemporarily = () => {
if (kind !== "video") return;
setControlsVisible(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(
() => setControlsVisible(false),
HIDE_DELAY,
);
}
};
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
const nextMuted = v > 0 && muted ? false : muted;
setVolume(v);
mediaRef.current!.volume = v;
if (nextMuted !== muted) {
setMuted(nextMuted);
mediaRef.current!.muted = nextMuted;
}
rememberVolume(v, nextMuted);
};
const toggleMute = () => {
const next = !muted;
setMuted(next);
mediaRef.current!.muted = next;
rememberVolume(volume, next);
};
const goFullscreen = () => {
(mediaRef.current as HTMLVideoElement).requestFullscreen?.();
};
// A waveform needs the raw bytes, which cross-origin streams won't hand over.
// Those get an honest progress bar rather than a permanent loading skeleton.
const track = kind === "audio" && trackStyle === "waveform"
? (
<Waveform
src={src}
current={current}
duration={duration}
onSeek={seekTo}
/>
)
: (
<SeekBar
current={current}
duration={duration}
onSeek={seekTo}
onDragChange={setDragging}
className={kind === "audio" ? "audio-player-track--stream" : undefined}
/>
);
const controls = (
<>
<button
type="button"
className="audio-player-btn"
onClick={toggle}
aria-label={playing ? t`Pause` : t`Play`}
>
{playing ? <IconPause /> : <IconPlay />}
</button>
<span className="audio-player-time">{fmt(current)}</span>
{track}
<span className="audio-player-time">{fmt(duration)}</span>
<div className="audio-player-volume">
<button
type="button"
className="audio-player-vol-btn"
onClick={toggleMute}
aria-label={muted ? t`Unmute` : t`Mute`}
>
<IconVolume muted={muted} />
</button>
<div className="audio-player-track audio-player-track--volume">
<div
className="audio-player-fill"
style={{ width: `${(muted ? 0 : volume) * 100}%` }}
/>
<input
type="range"
className="audio-player-range"
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
onChange={changeVolume}
aria-label={t`Volume`}
/>
</div>
</div>
{kind === "video" && (
<button
type="button"
className="audio-player-vol-btn"
onClick={goFullscreen}
aria-label={t`Fullscreen`}
>
<IconFullscreen />
</button>
)}
</>
);
if (kind === "video") {
return (
<div
className={`video-player${
showingControls ? " video-player--controls-visible" : ""
}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => playing && setControlsVisible(false)}
>
<video
ref={mediaRef as React.RefObject<HTMLVideoElement>}
src={src}
poster={poster}
preload="metadata"
className="video-player-video"
onClick={toggle}
>
{mime && <source src={src} type={mime} />}
</video>
<div className="video-player-controls audio-player">
{controls}
</div>
</div>
);
}
return (
<div className="audio-player">
<audio
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={src}
preload="metadata"
>
{mime && <source src={src} type={mime} />}
</audio>
{controls}
</div>
);
}