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 aliveRef = useRef(true);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
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);
|
const durationRef = useRef(0);
|
||||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
// ≥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.
|
// 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
|
// 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).
|
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
|
||||||
const [buffering, setBuffering] = useState(false);
|
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);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
|
// 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
|
// 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;
|
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);
|
// 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
|
// 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.
|
// 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
|
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. */}
|
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
|
||||||
{!ready ? (
|
{!ready ? (
|
||||||
<span>{t("plex.player.loading")}</span>
|
<span>
|
||||||
|
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
|
||||||
|
</span>
|
||||||
) : buffering ? (
|
) : buffering ? (
|
||||||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1183,6 +1197,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
hlsRef={hlsRef}
|
hlsRef={hlsRef}
|
||||||
playable={detail?.playable ?? ""}
|
playable={detail?.playable ?? ""}
|
||||||
|
hwRef={hwRef}
|
||||||
sessionStartRef={sessionStartRef}
|
sessionStartRef={sessionStartRef}
|
||||||
absRef={absRef}
|
absRef={absRef}
|
||||||
durationRef={durationRef}
|
durationRef={durationRef}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function PlexStatsOverlay({
|
|||||||
videoRef,
|
videoRef,
|
||||||
hlsRef,
|
hlsRef,
|
||||||
playable,
|
playable,
|
||||||
|
hwRef,
|
||||||
sessionStartRef,
|
sessionStartRef,
|
||||||
absRef,
|
absRef,
|
||||||
durationRef,
|
durationRef,
|
||||||
@@ -20,6 +21,7 @@ export function PlexStatsOverlay({
|
|||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
hlsRef: RefObject<Hls | null>;
|
hlsRef: RefObject<Hls | null>;
|
||||||
playable: string;
|
playable: string;
|
||||||
|
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
|
||||||
sessionStartRef: RefObject<number>;
|
sessionStartRef: RefObject<number>;
|
||||||
absRef: RefObject<number>;
|
absRef: RefObject<number>;
|
||||||
durationRef: RefObject<number>;
|
durationRef: RefObject<number>;
|
||||||
@@ -75,6 +77,7 @@ export function PlexStatsOverlay({
|
|||||||
setRows(
|
setRows(
|
||||||
buildStatRows({
|
buildStatRows({
|
||||||
playable,
|
playable,
|
||||||
|
hwEncoder: hwRef.current,
|
||||||
transport: hls ? "hls" : "direct",
|
transport: hls ? "hls" : "direct",
|
||||||
videoWidth: v?.videoWidth ?? 0,
|
videoWidth: v?.videoWidth ?? 0,
|
||||||
videoHeight: v?.videoHeight ?? 0,
|
videoHeight: v?.videoHeight ?? 0,
|
||||||
@@ -91,7 +94,7 @@ export function PlexStatsOverlay({
|
|||||||
sample();
|
sample();
|
||||||
const iv = setInterval(sample, 1000);
|
const iv = setInterval(sample, 1000);
|
||||||
return () => clearInterval(iv);
|
return () => clearInterval(iv);
|
||||||
}, [videoRef, hlsRef, playable, sessionStartRef, absRef, durationRef]);
|
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"buffering": "Buffering…",
|
"buffering": "Buffering…",
|
||||||
|
"preparing": "Preparing stream…",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"skipIntro": "Skip intro",
|
"skipIntro": "Skip intro",
|
||||||
"skipCredits": "Skip credits",
|
"skipCredits": "Skip credits",
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"buffering": "Pufferelés…",
|
"buffering": "Pufferelés…",
|
||||||
|
"preparing": "Stream előkészítése…",
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
"skipIntro": "Intro átugrása",
|
"skipIntro": "Intro átugrása",
|
||||||
"skipCredits": "Stáblista átugrása",
|
"skipCredits": "Stáblista átugrása",
|
||||||
|
|||||||
@@ -390,6 +390,8 @@ export interface PlexPlaySession {
|
|||||||
mode: "direct" | "hls";
|
mode: "direct" | "hls";
|
||||||
url: string;
|
url: string;
|
||||||
start_s: number;
|
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
|
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
fmtCodec,
|
fmtCodec,
|
||||||
fmtFrames,
|
fmtFrames,
|
||||||
fmtLevel,
|
fmtLevel,
|
||||||
|
fmtMode,
|
||||||
fmtRes,
|
fmtRes,
|
||||||
type PlexStatsInput,
|
type PlexStatsInput,
|
||||||
} from "./plexStats";
|
} from "./plexStats";
|
||||||
@@ -110,7 +111,25 @@ describe("buildStatRows", () => {
|
|||||||
expect(rows.find((r) => r.key === "position")?.value).toBe("2:10 / 86:54");
|
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", () => {
|
it("shows an empty classification honestly as a dash", () => {
|
||||||
expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(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.
|
// about — direct copy, remux (stream-copy → HLS), or transcode (re-encode). Empty until detail loads.
|
||||||
playable: string; // "direct" | "remux" | "transcode" | ""
|
playable: string; // "direct" | "remux" | "transcode" | ""
|
||||||
transport: "direct" | "hls"; // session transport (raw file vs HLS) — orthogonal to `playable`
|
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)
|
videoWidth: number; // intrinsic decoded dims (0 while the browser can't decode / pre-metadata)
|
||||||
videoHeight: number;
|
videoHeight: number;
|
||||||
droppedFrames: number; // getVideoPlaybackQuality().droppedVideoFrames
|
droppedFrames: number; // getVideoPlaybackQuality().droppedVideoFrames
|
||||||
@@ -89,11 +93,21 @@ export function fmtCodec(video: string | undefined, audio: string | undefined):
|
|||||||
return [v, a].filter(Boolean).join(" / ");
|
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
|
// 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.
|
// (honest "not available" beats a missing row) — except HLS-only rows, dropped entirely for direct play.
|
||||||
export function buildStatRows(s: PlexStatsInput): StatRow[] {
|
export function buildStatRows(s: PlexStatsInput): StatRow[] {
|
||||||
const rows: 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: "transport", labelKey: "plex.player.stats.transport", value: s.transport },
|
||||||
{ key: "res", labelKey: "plex.player.stats.resolution", value: fmtRes(s.videoWidth, s.videoHeight) },
|
{ key: "res", labelKey: "plex.player.stats.resolution", value: fmtRes(s.videoWidth, s.videoHeight) },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user