Files
gerbeur/src/components/player/MediaPlayer.tsx
khannurien 563daa720c
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
v3: src/ is documented like api/ and shared/, and the reference covers the whole repository — the frontend had no module docs, JSDoc on some components and hooks and not on others, props typed inline in half the components and in private interfaces in the rest, // comments on declarations, and no return types on 147 exported functions, so deno doc --lint over src/ found 637 errors. The rule api/ follows is applied to every non-test module of src/: a /** … @module */ block, JSDoc on every export and every field of an exported interface, /** */ for declarations and // for notes, verbs for functions ("Renders…" for a component), parameters named in the prose, {@link} only for symbols in scope, and a page's JSDoc names its route and who may see it (App.tsx's module doc lists the route table). For deno doc to show them, every exported function states its return type: components React.JSX.Element (or | null, or a portal), memo and forwardRef components their exotic types, contexts React.Context<…>, and a hook returning several things a documented Use<Name>Result interface (useAuth, useRequiredAuth, useFading, useEmojiTrigger, useMentionAutocomplete, useDumpCardLink, usePlayRichContent; FeedCacheResult, YouTubePlaylistControls and UseApiFormReturn are renamed to match). Every component that takes props takes an exported, documented <Name>Props interface — the 64 inline prop types are lifted into one, DumpFeed gets DumpFeedProps extending MainFeedProps. React types go through the global React namespace: the four default imports of react are gone, since deno doc can't follow them. Two duplicates go too: useMentionAutocomplete's UserResult was shared/'s UserSuggestion, and JournalCard re-exported JournalShape for no one; i18n.ts no longer re-exports Lingui's i18n, which its three users now import from @lingui/core. deno task doc and doc:lint now take src/ (but its tests and the generated locales), the index page gains a Frontend section linking every module by layer and domain, mod.test.ts walks src/ as well, and the docs workflow runs on changes to src/. deno doc doesn't document npm packages, so it takes a type imported from one — react-hook-form's FieldValues in the form fields, frimousse's SkinTone — for a private one; doc:lint now goes through doc-lint.ts, which fails on every diagnostic but those, 25 of them. The README states the frontend's part of the rule. No behaviour changes; 205 backend and 628 frontend tests pass, and tsc, deno check and deno lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:31:22 +00:00

423 lines
13 KiB
TypeScript

/**
* The site's audio and video player.
*
* @module
*/
import { useEffect, useRef, useState } from "react";
import { fmt } from "../../utils/player/duration.ts";
import { t } from "@lingui/core/macro";
import {
IconFullscreen,
IconPause,
IconPlay,
IconVolume,
} from "./PlayerIcons.tsx";
import { SeekBar } from "./SeekBar.tsx";
import { Waveform } from "./Waveform.tsx";
import { rememberVolume, storedVolume } from "../../utils/player/volume.ts";
/** How long a playing video's controls stay after the pointer stops, in ms. */
const HIDE_DELAY = 2500;
/** The props of {@link MediaPlayer}. */
export interface MediaPlayerProps {
/** The media's URL. */
src: string;
/** Whether it is audio or video. */
kind: "audio" | "video";
/** The media's type. */
mime?: string;
/** A video's still before it plays. */
poster?: string;
/** Whether it starts playing on mount. */
autoplay?: boolean;
/** Where it starts, in seconds. */
startTime?: number;
/** Called when it starts or stops playing. */
onPlayStateChange?: (playing: boolean) => void;
/** Called as it plays, with the playhead and the duration. */
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";
/** Receives its seek handle, for the global player's controls. */
seekRef?: { current: ((t: number) => void) | null };
/** Receives its play/pause handle, for the global player's controls. */
toggleRef?: { current: (() => void) | null };
}
/**
* Renders a media element with the site's own controls: play/pause, a waveform
* or seek bar, time, volume (remembered across players) and, for video,
* fullscreen.
*/
export function MediaPlayer(
{
src,
kind,
mime,
poster,
autoplay,
startTime,
onPlayStateChange,
onTimeUpdate,
onEnded,
onError,
trackStyle = "waveform",
seekRef,
toggleRef,
}: MediaPlayerProps,
): React.JSX.Element {
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>
);
}