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:
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Clock,
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
|
Gauge,
|
||||||
Info,
|
Info,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Languages,
|
Languages,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||||
|
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||||
import {
|
import {
|
||||||
useAutoSkip,
|
useAutoSkip,
|
||||||
useControlsVisibility,
|
useControlsVisibility,
|
||||||
@@ -171,6 +173,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const infoOpenRef = useRef(false);
|
const infoOpenRef = useRef(false);
|
||||||
infoOpenRef.current = infoOpen;
|
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();
|
const now = useWallClock();
|
||||||
|
|
||||||
durationRef.current = duration;
|
durationRef.current = duration;
|
||||||
@@ -533,6 +539,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
setTracksOpen(false);
|
setTracksOpen(false);
|
||||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||||
else if (infoOpenRef.current) setInfoOpen(false);
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
|
else if (statsOpenRef.current) setStatsOpen(false);
|
||||||
else onClose();
|
else onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
@@ -618,6 +625,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
case "I":
|
case "I":
|
||||||
setInfoOpen((v) => !v);
|
setInfoOpen((v) => !v);
|
||||||
break;
|
break;
|
||||||
|
case "d":
|
||||||
|
case "D":
|
||||||
|
setStatsOpen((v) => !v);
|
||||||
|
break;
|
||||||
case "Backspace":
|
case "Backspace":
|
||||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -639,6 +650,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
setTracksOpen(false);
|
setTracksOpen(false);
|
||||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||||
else if (infoOpenRef.current) setInfoOpen(false);
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
|
else if (statsOpenRef.current) setStatsOpen(false);
|
||||||
else if (!fullscreenElement()) onClose();
|
else if (!fullscreenElement()) onClose();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1124,6 +1136,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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)}>
|
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||||
<Info className="w-5 h-5" />
|
<Info className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
@@ -1140,6 +1155,20 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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). */}
|
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||||||
{helpOpen && (
|
{helpOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -1180,6 +1209,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
[["S"], "plex.player.keys.subtitle"],
|
[["S"], "plex.player.keys.subtitle"],
|
||||||
[["F"], "plex.player.keys.fullscreen"],
|
[["F"], "plex.player.keys.fullscreen"],
|
||||||
[["I"], "plex.player.keys.info"],
|
[["I"], "plex.player.keys.info"],
|
||||||
|
[["D"], "plex.player.keys.stats"],
|
||||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||||
[["H"], "plex.player.keys.help"],
|
[["H"], "plex.player.keys.help"],
|
||||||
] as [string[], string][]
|
] 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -189,7 +189,21 @@
|
|||||||
"help": "Show this help",
|
"help": "Show this help",
|
||||||
"audio": "Cycle audio track",
|
"audio": "Cycle audio track",
|
||||||
"subtitle": "Cycle subtitle",
|
"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",
|
"settings": "Settings",
|
||||||
"sync": "Offsets",
|
"sync": "Offsets",
|
||||||
|
|||||||
@@ -189,7 +189,21 @@
|
|||||||
"help": "Súgó megjelenítése",
|
"help": "Súgó megjelenítése",
|
||||||
"audio": "Hangsáv váltása",
|
"audio": "Hangsáv váltása",
|
||||||
"subtitle": "Felirat 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",
|
"settings": "Beállítások",
|
||||||
"sync": "Eltolások",
|
"sync": "Eltolások",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user