Files
siftlode/frontend/src/components/PlexStatsOverlay.tsx
T
peter c1058f1d5a refactor(player): the Plex session machine is a tested engine, one request per item in flight
PlexPlayer's session logic (generation, pending play intent, clock ownership, session offset, reclaim
budget, listener teardown) moves into lib/hlsEngine.ts, a plain class with hls.js behind lib/hlsPort.ts
and its own unit tests.

- A seek, track change or reclaim asked for while a session request is out replaces the queued
  target; it is sent once the in-flight answer settles. A burst of 20 presses past the buffer was 20
  requests and 20 ffmpeg spawns; it is now 2-3, and plays again ~2 s sooner.
- The player stops its server session when it leaves the item, closes or the page unloads
  (POST /api/plex/stream/{rk}/session/{sid}/stop, identity-aware). A left transcode used to run to
  the end of the film first.
- A restart asked for after a new session attached but before it played no longer reads the emptied
  media as the viewer's pause.
2026-10-06 22:31:48 +02:00

127 lines
4.7 KiB
TypeScript

import { type RefObject, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import type { HlsEngine } from "../lib/hlsEngine";
import { buildStatRows, type FragMeter, type StatRow } from "../lib/plexStats";
import { IconButton } from "./ui/IconButton";
// "Stats for nerds" debug overlay. Samples the live HTML5 <video> + 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 a verification surface (it shows
// the transcode mode + that the re-encode is engaged). Toggled with "d" / the gauge button / its X.
/** Dropped / total frames so far. Outside the component: the React Compiler cannot lower an
* optional call inside a try yet, and skipped the whole overlay for it. */
function frameCounts(v: HTMLVideoElement | null): { dropped: number; total: number } {
try {
const q = v?.getVideoPlaybackQuality?.();
if (q) return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames };
} catch {
/* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */
}
return { dropped: 0, total: 0 };
}
export function PlexStatsOverlay({
videoRef,
engine,
fragMeterRef,
audioLabel,
subLabel,
playable,
absRef,
durationRef,
onClose,
}: {
videoRef: RefObject<HTMLVideoElement | null>;
// The session machine: the hls.js level, the transcode encoder and the session's start offset.
engine: Pick<HlsEngine, "levelInfo" | "hw" | "tonemap" | "sessionStart">;
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
// long as this panel) — the source of the MEASURED bitrate row.
fragMeterRef: RefObject<FragMeter>;
audioLabel?: string; // selected audio track label, as the tracks menu names it
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
playable: string;
absRef: RefObject<number>;
durationRef: RefObject<number>;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [rows, setRows] = useState<StatRow[]>([]);
useEffect(() => {
const sample = () => {
const v = videoRef.current;
const { dropped, total } = frameCounts(v);
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;
}
}
}
const level = engine.levelInfo();
setRows(
buildStatRows({
lang,
playable,
hwEncoder: engine.hw,
tonemap: engine.tonemap,
transport: level ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
droppedFrames: dropped,
totalFrames: total,
bufferAheadS: bufferAhead,
hlsLevel: level,
measuredBps: fragMeterRef.current?.bitrate(),
audioLabel,
subLabel,
sessionStartS: engine.sessionStart.current,
abs: absRef.current ?? 0,
duration: durationRef.current ?? 0,
}),
);
};
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [lang, videoRef, engine, fragMeterRef, audioLabel, subLabel, playable, absRef, durationRef]);
return (
<div
role="region"
aria-label={t("plex.player.stats.title")}
onClick={(e) => 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"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("plex.player.stats.title")}
</span>
<IconButton variant="overlay" onClick={onClose} label={t("plex.player.close")}>
<X />
</IconButton>
</div>
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
reader announce the changing values continuously and drown out real player announcements.
The overlay is a role="region" (labelled above) a user reads on demand, not a status feed. */}
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-2xs">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-white/55">{t(r.labelKey)}</dt>
<dd className="text-right font-mono tabular-nums text-white/90">{r.value}</dd>
</div>
))}
</dl>
</div>
);
}