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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
"player": {
|
||||
"loading": "Loading…",
|
||||
"buffering": "Buffering…",
|
||||
"preparing": "Preparing stream…",
|
||||
"back": "Back",
|
||||
"skipIntro": "Skip intro",
|
||||
"skipCredits": "Skip credits",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user