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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user