feat(plex): stats-for-nerds debug overlay (E6 S2)

Add a toggleable stats overlay to the Plex player: active mode
(direct/remux/transcode), transport, resolution, codec, bitrate, HLS level,
dropped frames, buffer-ahead, session offset and position — sampled once a
second off the live videoRef + hlsRef (no data layer). Non-modal corner panel,
aria-live polite, dismissable; toggled with 'd', a gauge control-bar button, or
its X. Number-crunching lives in a pure, unit-tested plexStats module. Doubles
as the S1 transcode-verification surface. i18n HU/EN.
This commit is contained in:
2026-08-30 00:17:34 +02:00
parent 90ae9f6e88
commit 968674bc6a
6 changed files with 427 additions and 2 deletions
+30
View File
@@ -9,6 +9,7 @@ import {
ArrowLeft,
Clock,
Download,
Gauge,
Info,
Keyboard,
Languages,
@@ -40,6 +41,7 @@ import {
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import {
useAutoSkip,
useControlsVisibility,
@@ -171,6 +173,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const [infoOpen, setInfoOpen] = useState(false);
const infoOpenRef = useRef(false);
infoOpenRef.current = infoOpen;
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
const [statsOpen, setStatsOpen] = useState(false);
const statsOpenRef = useRef(false);
statsOpenRef.current = statsOpen;
const now = useWallClock();
durationRef.current = duration;
@@ -533,6 +539,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else onClose();
}, [onClose]);
@@ -618,6 +625,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
case "I":
setInfoOpen((v) => !v);
break;
case "d":
case "D":
setStatsOpen((v) => !v);
break;
case "Backspace":
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
e.preventDefault();
@@ -639,6 +650,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (!fullscreenElement()) onClose();
break;
}
@@ -1124,6 +1136,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
)}
</div>
)}
<Ctrl label={t("plex.player.stats.title")} onClick={() => setStatsOpen((v) => !v)}>
<Gauge className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
<Info className="w-5 h-5" />
</Ctrl>
@@ -1140,6 +1155,20 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
</div>
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
the video keeps playing and stays interactive behind it. */}
{statsOpen && (
<PlexStatsOverlay
videoRef={videoRef}
hlsRef={hlsRef}
playable={detail?.playable ?? ""}
sessionStartRef={sessionStartRef}
absRef={absRef}
durationRef={durationRef}
onClose={() => setStatsOpen(false)}
/>
)}
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && (
<div
@@ -1180,6 +1209,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
[["S"], "plex.player.keys.subtitle"],
[["F"], "plex.player.keys.fullscreen"],
[["I"], "plex.player.keys.info"],
[["D"], "plex.player.keys.stats"],
[["⌫", "Esc"], "plex.player.keys.back"],
[["H"], "plex.player.keys.help"],
] as [string[], string][]
@@ -0,0 +1,125 @@
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 HlsLevelInfo, type StatRow } from "../lib/plexStats";
// "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,
playable,
sessionStartRef,
absRef,
durationRef,
onClose,
}: {
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
playable: string;
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
onClose: () => void;
}) {
const { t } = useTranslation();
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({
playable,
transport: hls ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
droppedFrames: dropped,
totalFrames: total,
bufferAheadS: bufferAhead,
hlsLevel: level,
sessionStartS: sessionStartRef.current ?? 0,
abs: absRef.current ?? 0,
duration: durationRef.current ?? 0,
}),
);
};
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [videoRef, hlsRef, playable, 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>
<button
onClick={onClose}
aria-label={t("plex.player.back")}
className="rounded p-0.5 text-white/60 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
<dl aria-live="polite" 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>
);
}