From 90ae9f6e88b04a52dd6653f0e54cf0e5076e3d5e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 00:09:36 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(plex):=20on-screen=20=C2=B110s=20skip?= =?UTF-8?q?=20buttons=20flanking=20play/pause=20(E6=20S3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add discoverable rewind-10 / forward-10 controls to the Plex player control bar, flanking play/pause (D5-A). New Skip10 glyph: an outline circular arrow with a centred '10', deliberately distinct from the episode-level SkipBack/SkipForward and from Restart's plain RotateCcw. Reuses seekTo; mirrors the keyboard ←/→ (seekStep=10). i18n HU/EN aria-labels. --- frontend/src/components/PlexPlayer.tsx | 14 +++++- .../src/components/plexPlayerControls.tsx | 43 +++++++++++++++++++ frontend/src/i18n/locales/en/plex.json | 2 + frontend/src/i18n/locales/hu/plex.json | 2 + 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index c8e775c2..029808e0 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -38,7 +38,7 @@ import { subShadowCss, type PlexPlayerPrefs, } from "../lib/plexPlayerPrefs"; -import { Ctrl, PanelHead } from "./plexPlayerControls"; +import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls"; import { PlexPlayerSettings } from "./PlexPlayerSettings"; import { useAutoSkip, @@ -936,9 +936,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
- + seekTo(absRef.current - 10)} + align="start" + > + + + {playing ? : } + seekTo(absRef.current + 10)}> + + seekTo(0)}> diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx index 65ce5896..e764c52e 100644 --- a/frontend/src/components/plexPlayerControls.tsx +++ b/frontend/src/components/plexPlayerControls.tsx @@ -1,6 +1,49 @@ import { type ReactNode } from "react"; import { RotateCcw } from "lucide-react"; +// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native +// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double +// triangles) and from Restart's plain RotateCcw. The arrowhead is an L-corner at the arc's open end, +// the same construction lucide uses for RotateCcw/RotateCw, so it sits consistently in the bar. +export function Skip10({ dir }: { dir: "back" | "forward" }) { + const back = dir === "back"; + return ( + + ); +} + // --- settings-panel building blocks ------------------------------------------------------------ export function PanelHead({ children }: { children: ReactNode }) { return ( diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 44a30df4..dd529899 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -160,6 +160,8 @@ "skipCredits": "Skip credits", "playPause": "Play / pause (space)", "restart": "Restart from beginning", + "rewind10": "Back 10 seconds", + "forward10": "Forward 10 seconds", "prev": "Previous episode", "next": "Next episode", "mute": "Mute (m)", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 57ab000e..e1983c6d 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -160,6 +160,8 @@ "skipCredits": "Stáblista átugrása", "playPause": "Lejátszás / szünet (szóköz)", "restart": "Újra az elejéről", + "rewind10": "10 másodperc vissza", + "forward10": "10 másodperc előre", "prev": "Előző rész", "next": "Következő rész", "mute": "Némítás (m)", From 968674bc6aa4e79f2f739e1cb6278ed20a19b4ff Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 00:17:34 +0200 Subject: [PATCH 2/9] feat(plex): stats-for-nerds debug overlay (E6 S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a toggleable stats overlay to the Plex player: active mode (direct/remux/transcode), transport, resolution, codec, bitrate, HLS level, dropped frames, buffer-ahead, session offset and position — sampled once a second off the live videoRef + hlsRef (no data layer). Non-modal corner panel, aria-live polite, dismissable; toggled with 'd', a gauge control-bar button, or its X. Number-crunching lives in a pure, unit-tested plexStats module. Doubles as the S1 transcode-verification surface. i18n HU/EN. --- frontend/src/components/PlexPlayer.tsx | 30 +++++ frontend/src/components/PlexStatsOverlay.tsx | 125 ++++++++++++++++++ frontend/src/i18n/locales/en/plex.json | 16 ++- frontend/src/i18n/locales/hu/plex.json | 16 ++- frontend/src/lib/plexStats.test.ts | 116 +++++++++++++++++ frontend/src/lib/plexStats.ts | 126 +++++++++++++++++++ 6 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/PlexStatsOverlay.tsx create mode 100644 frontend/src/lib/plexStats.test.ts create mode 100644 frontend/src/lib/plexStats.ts diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 029808e0..7d9f0f9a 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -9,6 +9,7 @@ import { ArrowLeft, Clock, Download, + Gauge, Info, Keyboard, Languages, @@ -40,6 +41,7 @@ import { } from "../lib/plexPlayerPrefs"; import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls"; import { PlexPlayerSettings } from "./PlexPlayerSettings"; +import { PlexStatsOverlay } from "./PlexStatsOverlay"; import { useAutoSkip, useControlsVisibility, @@ -171,6 +173,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const [infoOpen, setInfoOpen] = useState(false); const infoOpenRef = useRef(false); infoOpenRef.current = infoOpen; + // "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…). + const [statsOpen, setStatsOpen] = useState(false); + const statsOpenRef = useRef(false); + statsOpenRef.current = statsOpen; const now = useWallClock(); durationRef.current = duration; @@ -533,6 +539,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { setTracksOpen(false); } else if (helpOpenRef.current) setHelpOpen(false); else if (infoOpenRef.current) setInfoOpen(false); + else if (statsOpenRef.current) setStatsOpen(false); else onClose(); }, [onClose]); @@ -618,6 +625,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { case "I": setInfoOpen((v) => !v); break; + case "d": + case "D": + setStatsOpen((v) => !v); + break; case "Backspace": // HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade). e.preventDefault(); @@ -639,6 +650,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { setTracksOpen(false); } else if (helpOpenRef.current) setHelpOpen(false); else if (infoOpenRef.current) setInfoOpen(false); + else if (statsOpenRef.current) setStatsOpen(false); else if (!fullscreenElement()) onClose(); break; } @@ -1124,6 +1136,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { )}
)} + setStatsOpen((v) => !v)}> + + setInfoOpen((v) => !v)}> @@ -1140,6 +1155,20 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { + {/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel — + the video keeps playing and stays interactive behind it. */} + {statsOpen && ( + setStatsOpen(false)} + /> + )} + {/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */} {helpOpen && (
+ hls.js state once a second +// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session +// offset. No data layer — the refs are already in hand. Doubles as the S1 verification surface (it shows +// the transcode mode + that the re-encode is engaged). Toggled with "d" / the gauge button / its X. +export function PlexStatsOverlay({ + videoRef, + hlsRef, + playable, + sessionStartRef, + absRef, + durationRef, + onClose, +}: { + videoRef: RefObject; + hlsRef: RefObject; + playable: string; + sessionStartRef: RefObject; + absRef: RefObject; + durationRef: RefObject; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [rows, setRows] = useState([]); + + useEffect(() => { + const sample = () => { + const v = videoRef.current; + const hls = hlsRef.current; + + let dropped = 0; + let total = 0; + try { + const q = v?.getVideoPlaybackQuality?.(); + if (q) { + dropped = q.droppedVideoFrames; + total = q.totalVideoFrames; + } + } catch { + /* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */ + } + + let bufferAhead = 0; + if (v) { + const now = v.currentTime; + for (let i = 0; i < v.buffered.length; i++) { + if (now >= v.buffered.start(i) && now <= v.buffered.end(i)) { + bufferAhead = v.buffered.end(i) - now; + break; + } + } + } + + let level: HlsLevelInfo | null = null; + if (hls) { + // currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate. + const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel; + const lv = idx >= 0 ? hls.levels?.[idx] : undefined; + level = { + index: hls.currentLevel, + total: hls.levels?.length ?? 0, + width: lv?.width, + height: lv?.height, + bitrate: lv?.bitrate, + videoCodec: lv?.videoCodec, + audioCodec: lv?.audioCodec, + }; + } + + setRows( + buildStatRows({ + playable, + transport: hls ? "hls" : "direct", + videoWidth: v?.videoWidth ?? 0, + videoHeight: v?.videoHeight ?? 0, + droppedFrames: dropped, + totalFrames: total, + bufferAheadS: bufferAhead, + hlsLevel: level, + sessionStartS: sessionStartRef.current ?? 0, + abs: absRef.current ?? 0, + duration: durationRef.current ?? 0, + }), + ); + }; + sample(); + const iv = setInterval(sample, 1000); + return () => clearInterval(iv); + }, [videoRef, hlsRef, playable, sessionStartRef, absRef, durationRef]); + + return ( +
e.stopPropagation()} + className="absolute left-4 top-16 z-menu w-64 rounded-xl border border-white/15 bg-black/80 p-3 text-white shadow-2xl backdrop-blur-sm" + > +
+ + {t("plex.player.stats.title")} + + +
+
+ {rows.map((r) => ( +
+
{t(r.labelKey)}
+
{r.value}
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index dd529899..b5cd9c57 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -189,7 +189,21 @@ "help": "Show this help", "audio": "Cycle audio track", "subtitle": "Cycle subtitle", - "fineSeek": "Fine seek" + "fineSeek": "Fine seek", + "stats": "Stats for nerds" + }, + "stats": { + "title": "Stats for nerds", + "mode": "Mode", + "transport": "Transport", + "resolution": "Resolution", + "codec": "Codec", + "bitrate": "Bitrate", + "level": "HLS level", + "frames": "Dropped frames", + "buffer": "Buffer ahead", + "offset": "Session offset", + "position": "Position" }, "settings": "Settings", "sync": "Offsets", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index e1983c6d..c603df34 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -189,7 +189,21 @@ "help": "Súgó megjelenítése", "audio": "Hangsáv váltása", "subtitle": "Felirat váltása", - "fineSeek": "Finom léptetés" + "fineSeek": "Finom léptetés", + "stats": "Statisztika (nerdöknek)" + }, + "stats": { + "title": "Statisztika (nerdöknek)", + "mode": "Mód", + "transport": "Átvitel", + "resolution": "Felbontás", + "codec": "Kodek", + "bitrate": "Bitráta", + "level": "HLS szint", + "frames": "Eldobott képkockák", + "buffer": "Puffer előre", + "offset": "Munkamenet-eltolás", + "position": "Pozíció" }, "settings": "Beállítások", "sync": "Eltolások", diff --git a/frontend/src/lib/plexStats.test.ts b/frontend/src/lib/plexStats.test.ts new file mode 100644 index 00000000..f368be66 --- /dev/null +++ b/frontend/src/lib/plexStats.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + buildStatRows, + fmtBitrate, + fmtBufferSecs, + fmtClock, + fmtCodec, + fmtFrames, + fmtLevel, + fmtRes, + type PlexStatsInput, +} from "./plexStats"; + +const DASH = "—"; + +describe("plexStats formatters", () => { + it("fmtBitrate scales kbps/Mbps and dashes zero/undefined", () => { + expect(fmtBitrate(undefined)).toBe(DASH); + expect(fmtBitrate(0)).toBe(DASH); + expect(fmtBitrate(820_000)).toBe("820 kbps"); + expect(fmtBitrate(4_200_000)).toBe("4.2 Mbps"); + expect(fmtBitrate(1_000_000)).toBe("1.0 Mbps"); + }); + + it("fmtBufferSecs shows one decimal, dashes negatives/non-finite", () => { + expect(fmtBufferSecs(12.34)).toBe("12.3 s"); + expect(fmtBufferSecs(0)).toBe("0.0 s"); + expect(fmtBufferSecs(-1)).toBe(DASH); + expect(fmtBufferSecs(Infinity)).toBe(DASH); + }); + + it("fmtFrames shows ratio + drop %, dashes when nothing decoded", () => { + expect(fmtFrames(0, 0)).toBe(DASH); + expect(fmtFrames(0, 1000)).toBe("0 / 1000 (0.00%)"); + expect(fmtFrames(5, 1000)).toBe("5 / 1000 (0.50%)"); + expect(fmtFrames(30, 1000)).toBe("30 / 1000 (3.0%)"); + }); + + it("fmtRes dashes an undecoded 0×0 frame", () => { + expect(fmtRes(1920, 1080)).toBe("1920×1080"); + expect(fmtRes(0, 0)).toBe(DASH); + expect(fmtRes(1920, 0)).toBe(DASH); + }); + + it("fmtClock renders m:ss and dashes bad input", () => { + expect(fmtClock(0)).toBe("0:00"); + expect(fmtClock(9)).toBe("0:09"); + expect(fmtClock(75)).toBe("1:15"); + expect(fmtClock(3661)).toBe("61:01"); + expect(fmtClock(-1)).toBe(DASH); + }); + + it("fmtLevel is 1-based with resolution, handles auto and empty", () => { + expect(fmtLevel(null)).toBe(DASH); + expect(fmtLevel({ index: 1, total: 4, width: 1280, height: 720 })).toBe("2 / 4 · 1280×720"); + expect(fmtLevel({ index: -1, total: 3 })).toBe("auto / 3"); + expect(fmtLevel({ index: 0, total: 0 })).toBe(DASH); + }); + + it("fmtCodec strips profile suffixes and dashes when absent", () => { + expect(fmtCodec("avc1.640028", "mp4a.40.2")).toBe("avc1 / mp4a"); + expect(fmtCodec("hvc1.1.6.L120", undefined)).toBe("hvc1"); + expect(fmtCodec(undefined, undefined)).toBe(DASH); + }); +}); + +describe("buildStatRows", () => { + const base: PlexStatsInput = { + playable: "remux", + transport: "hls", + videoWidth: 1280, + videoHeight: 720, + droppedFrames: 2, + totalFrames: 500, + bufferAheadS: 8.2, + hlsLevel: { index: 0, total: 1, width: 1280, height: 720, bitrate: 3_000_000 }, + sessionStartS: 65, + abs: 130, + duration: 5214, + }; + + it("includes the HLS-only rows (codec/bitrate/level) for hls transport", () => { + const keys = buildStatRows(base).map((r) => r.key); + expect(keys).toEqual([ + "mode", + "transport", + "res", + "codec", + "bitrate", + "level", + "frames", + "buffer", + "offset", + "position", + ]); + }); + + it("drops the HLS-only rows for direct play", () => { + const keys = buildStatRows({ ...base, transport: "direct", hlsLevel: null }).map((r) => r.key); + expect(keys).not.toContain("codec"); + expect(keys).not.toContain("bitrate"); + expect(keys).not.toContain("level"); + expect(keys).toContain("res"); + }); + + it("surfaces the classification as the mode value and formats position", () => { + const rows = buildStatRows({ ...base, playable: "transcode" }); + expect(rows.find((r) => r.key === "mode")?.value).toBe("transcode"); + expect(rows.find((r) => r.key === "offset")?.value).toBe("1:05"); + expect(rows.find((r) => r.key === "position")?.value).toBe("2:10 / 86:54"); + }); + + it("shows an empty classification honestly as a dash", () => { + expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(DASH); + }); +}); diff --git a/frontend/src/lib/plexStats.ts b/frontend/src/lib/plexStats.ts new file mode 100644 index 00000000..b950daf6 --- /dev/null +++ b/frontend/src/lib/plexStats.ts @@ -0,0 +1,126 @@ +// 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