+ 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
;
+ hlsRef: RefObject;
+ playable: string;
+ sessionStartRef: RefObject;
+ absRef: RefObject;
+ durationRef: RefObject;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation();
+ const [rows, setRows] = useState([]);
+
+ 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 (
+ 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"
+ >
+
+
+ {t("plex.player.stats.title")}
+
+
+
+
+ {rows.map((r) => (
+
+
- {t(r.labelKey)}
+ - {r.value}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json
index dd529899..b5cd9c57 100644
--- a/frontend/src/i18n/locales/en/plex.json
+++ b/frontend/src/i18n/locales/en/plex.json
@@ -189,7 +189,21 @@
"help": "Show this help",
"audio": "Cycle audio track",
"subtitle": "Cycle subtitle",
- "fineSeek": "Fine seek"
+ "fineSeek": "Fine seek",
+ "stats": "Stats for nerds"
+ },
+ "stats": {
+ "title": "Stats for nerds",
+ "mode": "Mode",
+ "transport": "Transport",
+ "resolution": "Resolution",
+ "codec": "Codec",
+ "bitrate": "Bitrate",
+ "level": "HLS level",
+ "frames": "Dropped frames",
+ "buffer": "Buffer ahead",
+ "offset": "Session offset",
+ "position": "Position"
},
"settings": "Settings",
"sync": "Offsets",
diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json
index e1983c6d..c603df34 100644
--- a/frontend/src/i18n/locales/hu/plex.json
+++ b/frontend/src/i18n/locales/hu/plex.json
@@ -189,7 +189,21 @@
"help": "Súgó megjelenítése",
"audio": "Hangsáv váltása",
"subtitle": "Felirat váltása",
- "fineSeek": "Finom léptetés"
+ "fineSeek": "Finom léptetés",
+ "stats": "Statisztika (nerdöknek)"
+ },
+ "stats": {
+ "title": "Statisztika (nerdöknek)",
+ "mode": "Mód",
+ "transport": "Átvitel",
+ "resolution": "Felbontás",
+ "codec": "Kodek",
+ "bitrate": "Bitráta",
+ "level": "HLS szint",
+ "frames": "Eldobott képkockák",
+ "buffer": "Puffer előre",
+ "offset": "Munkamenet-eltolás",
+ "position": "Pozíció"
},
"settings": "Beállítások",
"sync": "Eltolások",
diff --git a/frontend/src/lib/plexStats.test.ts b/frontend/src/lib/plexStats.test.ts
new file mode 100644
index 00000000..f368be66
--- /dev/null
+++ b/frontend/src/lib/plexStats.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildStatRows,
+ fmtBitrate,
+ fmtBufferSecs,
+ fmtClock,
+ fmtCodec,
+ fmtFrames,
+ fmtLevel,
+ fmtRes,
+ type PlexStatsInput,
+} from "./plexStats";
+
+const DASH = "—";
+
+describe("plexStats formatters", () => {
+ it("fmtBitrate scales kbps/Mbps and dashes zero/undefined", () => {
+ expect(fmtBitrate(undefined)).toBe(DASH);
+ expect(fmtBitrate(0)).toBe(DASH);
+ expect(fmtBitrate(820_000)).toBe("820 kbps");
+ expect(fmtBitrate(4_200_000)).toBe("4.2 Mbps");
+ expect(fmtBitrate(1_000_000)).toBe("1.0 Mbps");
+ });
+
+ it("fmtBufferSecs shows one decimal, dashes negatives/non-finite", () => {
+ expect(fmtBufferSecs(12.34)).toBe("12.3 s");
+ expect(fmtBufferSecs(0)).toBe("0.0 s");
+ expect(fmtBufferSecs(-1)).toBe(DASH);
+ expect(fmtBufferSecs(Infinity)).toBe(DASH);
+ });
+
+ it("fmtFrames shows ratio + drop %, dashes when nothing decoded", () => {
+ expect(fmtFrames(0, 0)).toBe(DASH);
+ expect(fmtFrames(0, 1000)).toBe("0 / 1000 (0.00%)");
+ expect(fmtFrames(5, 1000)).toBe("5 / 1000 (0.50%)");
+ expect(fmtFrames(30, 1000)).toBe("30 / 1000 (3.0%)");
+ });
+
+ it("fmtRes dashes an undecoded 0×0 frame", () => {
+ expect(fmtRes(1920, 1080)).toBe("1920×1080");
+ expect(fmtRes(0, 0)).toBe(DASH);
+ expect(fmtRes(1920, 0)).toBe(DASH);
+ });
+
+ it("fmtClock renders m:ss and dashes bad input", () => {
+ expect(fmtClock(0)).toBe("0:00");
+ expect(fmtClock(9)).toBe("0:09");
+ expect(fmtClock(75)).toBe("1:15");
+ expect(fmtClock(3661)).toBe("61:01");
+ expect(fmtClock(-1)).toBe(DASH);
+ });
+
+ it("fmtLevel is 1-based with resolution, handles auto and empty", () => {
+ expect(fmtLevel(null)).toBe(DASH);
+ expect(fmtLevel({ index: 1, total: 4, width: 1280, height: 720 })).toBe("2 / 4 · 1280×720");
+ expect(fmtLevel({ index: -1, total: 3 })).toBe("auto / 3");
+ expect(fmtLevel({ index: 0, total: 0 })).toBe(DASH);
+ });
+
+ it("fmtCodec strips profile suffixes and dashes when absent", () => {
+ expect(fmtCodec("avc1.640028", "mp4a.40.2")).toBe("avc1 / mp4a");
+ expect(fmtCodec("hvc1.1.6.L120", undefined)).toBe("hvc1");
+ expect(fmtCodec(undefined, undefined)).toBe(DASH);
+ });
+});
+
+describe("buildStatRows", () => {
+ const base: PlexStatsInput = {
+ playable: "remux",
+ transport: "hls",
+ videoWidth: 1280,
+ videoHeight: 720,
+ droppedFrames: 2,
+ totalFrames: 500,
+ bufferAheadS: 8.2,
+ hlsLevel: { index: 0, total: 1, width: 1280, height: 720, bitrate: 3_000_000 },
+ sessionStartS: 65,
+ abs: 130,
+ duration: 5214,
+ };
+
+ it("includes the HLS-only rows (codec/bitrate/level) for hls transport", () => {
+ const keys = buildStatRows(base).map((r) => r.key);
+ expect(keys).toEqual([
+ "mode",
+ "transport",
+ "res",
+ "codec",
+ "bitrate",
+ "level",
+ "frames",
+ "buffer",
+ "offset",
+ "position",
+ ]);
+ });
+
+ it("drops the HLS-only rows for direct play", () => {
+ const keys = buildStatRows({ ...base, transport: "direct", hlsLevel: null }).map((r) => r.key);
+ expect(keys).not.toContain("codec");
+ expect(keys).not.toContain("bitrate");
+ expect(keys).not.toContain("level");
+ expect(keys).toContain("res");
+ });
+
+ it("surfaces the classification as the mode value and formats position", () => {
+ const rows = buildStatRows({ ...base, playable: "transcode" });
+ expect(rows.find((r) => r.key === "mode")?.value).toBe("transcode");
+ expect(rows.find((r) => r.key === "offset")?.value).toBe("1:05");
+ expect(rows.find((r) => r.key === "position")?.value).toBe("2:10 / 86:54");
+ });
+
+ it("shows an empty classification honestly as a dash", () => {
+ expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(DASH);
+ });
+});
diff --git a/frontend/src/lib/plexStats.ts b/frontend/src/lib/plexStats.ts
new file mode 100644
index 00000000..b950daf6
--- /dev/null
+++ b/frontend/src/lib/plexStats.ts
@@ -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