The production pass (7ed2ac4e, E32) asked "is this export used by what
ships?". No real hit: its only findings were 37 exports a test imports,
answered with 36 `@internal` JSDoc tags (E34 S2) — test-only exports, not
defects. Since then it has only ever asked for another tag. The tags go
with it (36, no longer read by anything), and so do the production-only
`!…!` exclusions in knip.jsonc and their register rows. Plain knip (unused
files, exports and dependencies) keeps running in the gate.
230 lines
10 KiB
TypeScript
230 lines
10 KiB
TypeScript
// 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).
|
||
// The numbers are in the UI language (E30 S3: "4,2 Mbps", "12,3 mp" in Hungarian); the unit symbols of a
|
||
// stream (Mbps, kbps) are the same in both.
|
||
|
||
import { formatDecimal, formatNumber, formatUnit } from "./format";
|
||
|
||
export interface PlexStatsInput {
|
||
lang: string; // the UI language, for the numbers
|
||
// 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`
|
||
// For a transcode session, which encoder actually ran: true = VAAPI hardware, false = libx264
|
||
// software, null/undefined = not a transcode (or unknown) — so the mode row can read "transcode · GPU"
|
||
// vs "transcode · CPU", the visible confirmation of which S1 path engaged.
|
||
hwEncoder?: boolean | null;
|
||
// For a HW transcode of a 10-bit/HDR source: true = the GPU OpenCL tonemap path (HDR→SDR) engaged, so
|
||
// the mode row can read "transcode · GPU · HDR→SDR". Absent/false for an 8-bit HW or software session.
|
||
tonemap?: boolean | null;
|
||
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
|
||
// MEASURED stream bitrate: bytes actually delivered per second of media, from hls.js's per-fragment
|
||
// load stats. Undefined until the first fragment lands (an honest dash — never a stand-in number).
|
||
// This row used to read the master playlist's BANDWIDTH, which for a remux session ffmpeg derives
|
||
// from the one thing it knows, the `-b:a 192k` AAC track, plus 10%: a constant 211 kbps on every
|
||
// remux regardless of the actual video. That is what the viewer correctly refused to believe.
|
||
measuredBps?: number;
|
||
audioLabel?: string; // the selected audio track, as the tracks menu names it
|
||
subLabel?: string; // the selected subtitle track (or the menu's "Off" entry)
|
||
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, lang: string): string {
|
||
if (!bps || bps <= 0) return DASH;
|
||
if (bps >= 1_000_000) return `${formatDecimal(bps / 1_000_000, lang)} Mbps`;
|
||
return `${formatNumber(Math.round(bps / 1000), lang)} kbps`;
|
||
}
|
||
|
||
// Seconds of buffer ahead of the playhead → "12.3 sec" / "12,3 mp".
|
||
export function fmtBufferSecs(s: number, lang: string): string {
|
||
if (!Number.isFinite(s) || s < 0) return DASH;
|
||
return formatUnit(s, "second", lang, 1);
|
||
}
|
||
|
||
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
|
||
export function fmtFrames(dropped: number, total: number, lang: string): string {
|
||
if (total <= 0) return DASH;
|
||
const pct = (dropped / total) * 100;
|
||
const n = (x: number) => formatNumber(x, lang);
|
||
return `${n(dropped)} / ${n(total)} (${formatDecimal(pct, lang, 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(" / ");
|
||
}
|
||
|
||
// Active mode, with the transcode encoder appended when known: "transcode · GPU" (VAAPI hardware) or
|
||
// "transcode · CPU" (libx264 software) — the at-a-glance confirmation of which S1 path engaged — and a
|
||
// "· HDR→SDR" tag when the GPU OpenCL tonemap path handled a 10-bit/HDR source ("transcode · GPU ·
|
||
// HDR→SDR"). Non-transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens
|
||
// (not i18n'd), consistent with the other technical values in this overlay (e.g. transport "hls").
|
||
export function fmtMode(
|
||
playable: string,
|
||
hwEncoder?: boolean | null,
|
||
tonemap?: boolean | null,
|
||
): string {
|
||
const mode = playable || DASH;
|
||
if (playable !== "transcode" || hwEncoder == null) return mode;
|
||
const base = `${mode} · ${hwEncoder ? "GPU" : "CPU"}`;
|
||
return tonemap ? `${base} · HDR→SDR` : base;
|
||
}
|
||
|
||
// 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: fmtMode(s.playable, s.hwEncoder, s.tonemap),
|
||
},
|
||
{ 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.measuredBps, s.lang),
|
||
},
|
||
{ key: "level", labelKey: "plex.player.stats.level", value: fmtLevel(s.hlsLevel) },
|
||
);
|
||
}
|
||
rows.push(
|
||
// Which audio/subtitle track is actually playing. The player has switched both since E5 and the
|
||
// panel never said which one you landed on — the one question "stats for nerds" was being asked.
|
||
{ key: "audio", labelKey: "plex.player.stats.audio", value: s.audioLabel || DASH },
|
||
{ key: "subtitle", labelKey: "plex.player.stats.subtitle", value: s.subLabel || DASH },
|
||
{
|
||
key: "frames",
|
||
labelKey: "plex.player.stats.frames",
|
||
value: fmtFrames(s.droppedFrames, s.totalFrames, s.lang),
|
||
},
|
||
{
|
||
key: "buffer",
|
||
labelKey: "plex.player.stats.buffer",
|
||
value: fmtBufferSecs(s.bufferAheadS, s.lang),
|
||
},
|
||
{ 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;
|
||
}
|
||
|
||
// --- measured bitrate ---------------------------------------------------------------------------
|
||
|
||
interface FragSample {
|
||
type: string; // hls.js fragment type: "main" (video, and muxed audio) / "audio" / "subtitle"
|
||
bytes: number;
|
||
durationS: number; // MEDIA seconds the fragment carries (not how long it took to download)
|
||
}
|
||
|
||
export interface FragMeter {
|
||
add(sample: FragSample): void;
|
||
/** Bits per second of media, or undefined while nothing has been measured yet. */
|
||
bitrate(): number | undefined;
|
||
reset(): void;
|
||
}
|
||
|
||
/** Rolling meter over hls.js FRAG_LOADED stats: bytes delivered per second of MEDIA, which is the
|
||
* stream's real bitrate — as opposed to download throughput, which only measures the network.
|
||
*
|
||
* Kept per fragment TYPE and summed: in a multi-audio session the audio ships as its own rendition,
|
||
* so video and audio fragments cover the SAME media seconds. Pooling their bytes over the pooled
|
||
* durations would report half the truth; per-type rates add up to the whole. */
|
||
export function createFragMeter(window = 10): FragMeter {
|
||
const byType = new Map<string, FragSample[]>();
|
||
return {
|
||
add(sample) {
|
||
if (!(sample.bytes > 0) || !(sample.durationS > 0)) return; // pre-roll / init segments
|
||
const list = byType.get(sample.type) ?? [];
|
||
list.push(sample);
|
||
if (list.length > window) list.shift();
|
||
byType.set(sample.type, list);
|
||
},
|
||
bitrate() {
|
||
let total = 0;
|
||
for (const list of byType.values()) {
|
||
const bytes = list.reduce((n, f) => n + f.bytes, 0);
|
||
const secs = list.reduce((n, f) => n + f.durationS, 0);
|
||
if (secs > 0) total += (bytes * 8) / secs;
|
||
}
|
||
return total > 0 ? total : undefined;
|
||
},
|
||
reset() {
|
||
byType.clear();
|
||
},
|
||
};
|
||
}
|