From dc24d353e0b8a6f1a2ea275c2a30ea43393111cd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 01:30:53 +0200 Subject: [PATCH] feat(plex): honest transcode cold-start + encoder in stats (E6 S1d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/src/components/PlexPlayer.tsx | 17 ++++++++++++++++- frontend/src/components/PlexStatsOverlay.tsx | 5 ++++- frontend/src/i18n/locales/en/plex.json | 1 + frontend/src/i18n/locales/hu/plex.json | 1 + frontend/src/lib/api/plex.ts | 2 ++ frontend/src/lib/plexStats.test.ts | 19 +++++++++++++++++++ frontend/src/lib/plexStats.ts | 16 +++++++++++++++- 7 files changed, 58 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index fabbe953..67fa33a4 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -98,6 +98,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const aliveRef = useRef(true); const wrapRef = useRef(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(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(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 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 ? ( - {t("plex.player.loading")} + + {t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")} + ) : buffering ? ( {t("plex.player.buffering")} ) : 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} diff --git a/frontend/src/components/PlexStatsOverlay.tsx b/frontend/src/components/PlexStatsOverlay.tsx index 03e3e00f..ccfc5fd1 100644 --- a/frontend/src/components/PlexStatsOverlay.tsx +++ b/frontend/src/components/PlexStatsOverlay.tsx @@ -12,6 +12,7 @@ export function PlexStatsOverlay({ videoRef, hlsRef, playable, + hwRef, sessionStartRef, absRef, durationRef, @@ -20,6 +21,7 @@ export function PlexStatsOverlay({ videoRef: RefObject; hlsRef: RefObject; playable: string; + hwRef: RefObject; // active transcode encoder: true=GPU, false=CPU, null=not transcode sessionStartRef: RefObject; absRef: RefObject; durationRef: RefObject; @@ -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 (
{ 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); + }); +}); diff --git a/frontend/src/lib/plexStats.ts b/frontend/src/lib/plexStats.ts index b950daf6..b5ebbfd8 100644 --- a/frontend/src/lib/plexStats.ts +++ b/frontend/src/lib/plexStats.ts @@ -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) }, ];