feat(plex): honest transcode cold-start + encoder in stats (E6 S1d)

Surface the transcode session's runtime facts in the player. The session
response's transcode/hw flags now thread through: the stats overlay's mode row
reads "transcode · GPU" (VAAPI) or "transcode · CPU" (libx264) — the at-a-glance
confirmation of which S1 path engaged — and the initial-load overlay says
"Preparing stream…" instead of "Loading…" for a transcode session, since its
first segment is genuinely slower than a remux and shouldn't read as a hang.

fmtMode (pure, unit-tested) appends the encoder only for a transcode with a known
encoder. i18n HU/EN for the new preparing label. Browser-UAT'd on the XviD
fixture: mode row reads "transcode · CPU" live (software, no render node here).
This commit is contained in:
2026-08-30 01:30:53 +02:00
parent ce96032edb
commit dc24d353e0
7 changed files with 58 additions and 3 deletions
+16 -1
View File
@@ -98,6 +98,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
const durationRef = useRef(0);
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
@@ -117,6 +120,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
const [buffering, setBuffering] = useState(false);
// The current session re-encodes the video (playable="transcode"). Its cold-start (the first transcode
// segment) is genuinely slower than a remux stall, so the initial-load overlay says "Preparing stream…"
// instead of the generic "Loading…" — honest, and it stops a warm-up reading as a hang.
const [sessionIsTranscode, setSessionIsTranscode] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
@@ -219,6 +226,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
);
return;
}
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
setSessionIsTranscode(isTranscode);
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
@@ -807,7 +819,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
{!ready ? (
<span>{t("plex.player.loading")}</span>
<span>
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
</span>
) : buffering ? (
<span className="sr-only">{t("plex.player.buffering")}</span>
) : null}
@@ -1183,6 +1197,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
videoRef={videoRef}
hlsRef={hlsRef}
playable={detail?.playable ?? ""}
hwRef={hwRef}
sessionStartRef={sessionStartRef}
absRef={absRef}
durationRef={durationRef}
+4 -1
View File
@@ -12,6 +12,7 @@ export function PlexStatsOverlay({
videoRef,
hlsRef,
playable,
hwRef,
sessionStartRef,
absRef,
durationRef,
@@ -20,6 +21,7 @@ export function PlexStatsOverlay({
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
@@ -75,6 +77,7 @@ export function PlexStatsOverlay({
setRows(
buildStatRows({
playable,
hwEncoder: hwRef.current,
transport: hls ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
@@ -91,7 +94,7 @@ export function PlexStatsOverlay({
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [videoRef, hlsRef, playable, sessionStartRef, absRef, durationRef]);
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
return (
<div
+1
View File
@@ -155,6 +155,7 @@
"player": {
"loading": "Loading…",
"buffering": "Buffering…",
"preparing": "Preparing stream…",
"back": "Back",
"skipIntro": "Skip intro",
"skipCredits": "Skip credits",
+1
View File
@@ -155,6 +155,7 @@
"player": {
"loading": "Betöltés…",
"buffering": "Pufferelés…",
"preparing": "Stream előkészítése…",
"back": "Vissza",
"skipIntro": "Intro átugrása",
"skipCredits": "Stáblista átugrása",
+2
View File
@@ -390,6 +390,8 @@ export interface PlexPlaySession {
mode: "direct" | "hls";
url: string;
start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
}
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
+19
View File
@@ -7,6 +7,7 @@ import {
fmtCodec,
fmtFrames,
fmtLevel,
fmtMode,
fmtRes,
type PlexStatsInput,
} from "./plexStats";
@@ -110,7 +111,25 @@ describe("buildStatRows", () => {
expect(rows.find((r) => r.key === "position")?.value).toBe("2:10 / 86:54");
});
it("appends the transcode encoder to the mode row when known", () => {
const gpu = buildStatRows({ ...base, playable: "transcode", hwEncoder: true });
expect(gpu.find((r) => r.key === "mode")?.value).toBe("transcode · GPU");
const cpu = buildStatRows({ ...base, playable: "transcode", hwEncoder: false });
expect(cpu.find((r) => r.key === "mode")?.value).toBe("transcode · CPU");
});
it("shows an empty classification honestly as a dash", () => {
expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(DASH);
});
});
describe("fmtMode", () => {
it("appends the encoder only for a transcode with a known encoder", () => {
expect(fmtMode("transcode", true)).toBe("transcode · GPU");
expect(fmtMode("transcode", false)).toBe("transcode · CPU");
expect(fmtMode("transcode", null)).toBe("transcode"); // encoder unknown → bare mode
expect(fmtMode("transcode")).toBe("transcode");
expect(fmtMode("remux", true)).toBe("remux"); // not a transcode → no encoder suffix
expect(fmtMode("")).toBe(DASH);
});
});
+15 -1
View File
@@ -8,6 +8,10 @@ export interface PlexStatsInput {
// 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;
videoWidth: number; // intrinsic decoded dims (0 while the browser can't decode / pre-metadata)
videoHeight: number;
droppedFrames: number; // getVideoPlaybackQuality().droppedVideoFrames
@@ -89,11 +93,21 @@ export function fmtCodec(video: string | undefined, audio: string | undefined):
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. 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): string {
const mode = playable || DASH;
if (playable !== "transcode" || hwEncoder == null) return mode;
return `${mode} · ${hwEncoder ? "GPU" : "CPU"}`;
}
// 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: "mode", labelKey: "plex.player.stats.mode", value: fmtMode(s.playable, s.hwEncoder) },
{ key: "transport", labelKey: "plex.player.stats.transport", value: s.transport },
{ key: "res", labelKey: "plex.player.stats.resolution", value: fmtRes(s.videoWidth, s.videoHeight) },
];