Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
158 lines
5.3 KiB
TypeScript
158 lines
5.3 KiB
TypeScript
import { type RefObject, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type Hls from "hls.js";
|
|
import { X } from "lucide-react";
|
|
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
|
|
import { IconButton } from "./ui/IconButton";
|
|
|
|
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5 <video> + hls.js state once a second
|
|
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
|
|
// offset. No data layer — the refs are already in hand. Doubles as the S1 verification surface (it shows
|
|
// the transcode mode + that the re-encode is engaged). Toggled with "d" / the gauge button / its X.
|
|
export function PlexStatsOverlay({
|
|
videoRef,
|
|
hlsRef,
|
|
fragMeterRef,
|
|
audioLabel,
|
|
subLabel,
|
|
playable,
|
|
hwRef,
|
|
tonemapRef,
|
|
sessionStartRef,
|
|
absRef,
|
|
durationRef,
|
|
onClose,
|
|
}: {
|
|
videoRef: RefObject<HTMLVideoElement | null>;
|
|
hlsRef: RefObject<Hls | null>;
|
|
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
|
|
// long as this panel) — the source of the MEASURED bitrate row.
|
|
fragMeterRef: RefObject<FragMeter>;
|
|
audioLabel?: string; // selected audio track label, as the tracks menu names it
|
|
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
|
|
playable: string;
|
|
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
|
|
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
|
|
sessionStartRef: RefObject<number>;
|
|
absRef: RefObject<number>;
|
|
durationRef: RefObject<number>;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const [rows, setRows] = useState<StatRow[]>([]);
|
|
|
|
useEffect(() => {
|
|
const sample = () => {
|
|
const v = videoRef.current;
|
|
const hls = hlsRef.current;
|
|
|
|
let dropped = 0;
|
|
let total = 0;
|
|
try {
|
|
const q = v?.getVideoPlaybackQuality?.();
|
|
if (q) {
|
|
dropped = q.droppedVideoFrames;
|
|
total = q.totalVideoFrames;
|
|
}
|
|
} catch {
|
|
/* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */
|
|
}
|
|
|
|
let bufferAhead = 0;
|
|
if (v) {
|
|
const now = v.currentTime;
|
|
for (let i = 0; i < v.buffered.length; i++) {
|
|
if (now >= v.buffered.start(i) && now <= v.buffered.end(i)) {
|
|
bufferAhead = v.buffered.end(i) - now;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let level: HlsLevelInfo | null = null;
|
|
if (hls) {
|
|
// currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate.
|
|
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
|
|
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
|
|
level = {
|
|
index: hls.currentLevel,
|
|
total: hls.levels?.length ?? 0,
|
|
width: lv?.width,
|
|
height: lv?.height,
|
|
bitrate: lv?.bitrate,
|
|
videoCodec: lv?.videoCodec,
|
|
audioCodec: lv?.audioCodec,
|
|
};
|
|
}
|
|
|
|
setRows(
|
|
buildStatRows({
|
|
lang,
|
|
playable,
|
|
hwEncoder: hwRef.current,
|
|
tonemap: tonemapRef.current,
|
|
transport: hls ? "hls" : "direct",
|
|
videoWidth: v?.videoWidth ?? 0,
|
|
videoHeight: v?.videoHeight ?? 0,
|
|
droppedFrames: dropped,
|
|
totalFrames: total,
|
|
bufferAheadS: bufferAhead,
|
|
hlsLevel: level,
|
|
measuredBps: fragMeterRef.current?.bitrate(),
|
|
audioLabel,
|
|
subLabel,
|
|
sessionStartS: sessionStartRef.current ?? 0,
|
|
abs: absRef.current ?? 0,
|
|
duration: durationRef.current ?? 0,
|
|
}),
|
|
);
|
|
};
|
|
sample();
|
|
const iv = setInterval(sample, 1000);
|
|
return () => clearInterval(iv);
|
|
}, [
|
|
lang,
|
|
videoRef,
|
|
hlsRef,
|
|
fragMeterRef,
|
|
audioLabel,
|
|
subLabel,
|
|
playable,
|
|
hwRef,
|
|
tonemapRef,
|
|
sessionStartRef,
|
|
absRef,
|
|
durationRef,
|
|
]);
|
|
|
|
return (
|
|
<div
|
|
role="region"
|
|
aria-label={t("plex.player.stats.title")}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="absolute left-4 top-16 z-menu w-64 rounded-xl border border-white/15 bg-black/80 p-3 text-white shadow-2xl backdrop-blur-sm"
|
|
>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
|
|
{t("plex.player.stats.title")}
|
|
</span>
|
|
<IconButton variant="overlay" onClick={onClose} label={t("plex.player.close")}>
|
|
<X />
|
|
</IconButton>
|
|
</div>
|
|
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
|
|
reader announce the changing values continuously and drown out real player announcements.
|
|
The overlay is a role="region" (labelled above) a user reads on demand, not a status feed. */}
|
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-2xs">
|
|
{rows.map((r) => (
|
|
<div key={r.key} className="contents">
|
|
<dt className="text-white/55">{t(r.labelKey)}</dt>
|
|
<dd className="text-right font-mono tabular-nums text-white/90">{r.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|