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
+126
View File
@@ -0,0 +1,126 @@
// Pure formatters + row builder for the Plex player "stats for nerds" overlay (E6 S2). Kept free of
// React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef /
// hlsRef and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the
// HTML5 <video> + hls.js already expose everything (intrinsic dims, getVideoPlaybackQuality, levels).
export interface PlexStatsInput {
// The item's codec classification (backend `classify_playable`): the "active mode" a viewer cares
// about — direct copy, remux (stream-copy → HLS), or transcode (re-encode). Empty until detail loads.
playable: string; // "direct" | "remux" | "transcode" | ""
transport: "direct" | "hls"; // session transport (raw file vs HLS) — orthogonal to `playable`
videoWidth: number; // intrinsic decoded dims (0 while the browser can't decode / pre-metadata)
videoHeight: number;
droppedFrames: number; // getVideoPlaybackQuality().droppedVideoFrames
totalFrames: number; // getVideoPlaybackQuality().totalVideoFrames
bufferAheadS: number; // seconds buffered ahead of the playhead
hlsLevel: HlsLevelInfo | null; // null for direct play / Safari-native HLS
sessionStartS: number; // media-clock offset the current session was opened at
abs: number; // current media time (absolute, session offset already folded in)
duration: number;
}
export interface HlsLevelInfo {
index: number; // active level index (-1 = auto/undetermined)
total: number; // number of renditions
width?: number;
height?: number;
bitrate?: number; // bits per second
videoCodec?: string;
audioCodec?: string;
}
export interface StatRow {
key: string; // stable react key
labelKey: string; // i18n key under plex.player.stats.*
value: string;
}
const DASH = "—";
// bits-per-second → "4.2 Mbps" / "820 kbps". Undefined/0 → dash (hls.js reports 0 before a level loads).
export function fmtBitrate(bps: number | undefined): string {
if (!bps || bps <= 0) return DASH;
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
return `${Math.round(bps / 1000)} kbps`;
}
// Seconds of buffer ahead of the playhead → "12.3 s".
export function fmtBufferSecs(s: number): string {
if (!Number.isFinite(s) || s < 0) return DASH;
return `${s.toFixed(1)} s`;
}
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
export function fmtFrames(dropped: number, total: number): string {
if (total <= 0) return DASH;
const pct = (dropped / total) * 100;
return `${dropped} / ${total} (${pct.toFixed(pct < 1 ? 2 : 1)}%)`;
}
// Intrinsic decoded resolution. 0×0 means the browser hasn't decoded (or can't) — an honest dash,
// which is itself the tell for a not-yet-playable codec pre-transcode.
export function fmtRes(w: number, h: number): string {
if (w <= 0 || h <= 0) return DASH;
return `${w}×${h}`;
}
// Seconds → m:ss (offsets/positions are small; hours are folded into minutes deliberately).
export function fmtClock(s: number): string {
if (!Number.isFinite(s) || s < 0) return DASH;
const total = Math.floor(s);
const m = Math.floor(total / 60);
const sec = total % 60;
return `${m}:${sec.toString().padStart(2, "0")}`;
}
// hls.js active level → "2 / 4 · 1280×720" (1-based index for humans). Auto/unknown level → dash + count.
export function fmtLevel(l: HlsLevelInfo | null): string {
if (!l || l.total <= 0) return DASH;
const idx = l.index >= 0 ? `${l.index + 1} / ${l.total}` : `auto / ${l.total}`;
const res = l.width && l.height ? ` · ${l.width}×${l.height}` : "";
return `${idx}${res}`;
}
// Video/audio codec from the active hls.js level (only HLS exposes them; direct play → dash).
export function fmtCodec(video: string | undefined, audio: string | undefined): string {
const v = video?.split(".")[0] || null; // "avc1.640028" → "avc1"
const a = audio?.split(".")[0] || null;
if (!v && !a) return DASH;
return [v, a].filter(Boolean).join(" / ");
}
// Build the ordered, i18n-keyed rows the overlay renders. Rows whose data is absent still show a dash
// (honest "not available" beats a missing row) — except HLS-only rows, dropped entirely for direct play.
export function buildStatRows(s: PlexStatsInput): StatRow[] {
const rows: StatRow[] = [
{ key: "mode", labelKey: "plex.player.stats.mode", value: s.playable || DASH },
{ key: "transport", labelKey: "plex.player.stats.transport", value: s.transport },
{ key: "res", labelKey: "plex.player.stats.resolution", value: fmtRes(s.videoWidth, s.videoHeight) },
];
if (s.transport === "hls") {
rows.push(
{
key: "codec",
labelKey: "plex.player.stats.codec",
value: fmtCodec(s.hlsLevel?.videoCodec, s.hlsLevel?.audioCodec),
},
{ key: "bitrate", labelKey: "plex.player.stats.bitrate", value: fmtBitrate(s.hlsLevel?.bitrate) },
{ key: "level", labelKey: "plex.player.stats.level", value: fmtLevel(s.hlsLevel) },
);
}
rows.push(
{
key: "frames",
labelKey: "plex.player.stats.frames",
value: fmtFrames(s.droppedFrames, s.totalFrames),
},
{ key: "buffer", labelKey: "plex.player.stats.buffer", value: fmtBufferSecs(s.bufferAheadS) },
{ key: "offset", labelKey: "plex.player.stats.offset", value: fmtClock(s.sessionStartS) },
{
key: "position",
labelKey: "plex.player.stats.position",
value: `${fmtClock(s.abs)} / ${fmtClock(s.duration)}`,
},
);
return rows;
}