fix(plex): the slow line says both rates in one unit

Each rate took its own unit, so a link under a megabit read "needs 1.4
Mbps, 800 kbps arrives". `fmtBitrates` picks the unit the largest takes
for all of them. The away-window header now says what ends an absence
outside hls.js (the answer) and after the error (the viewer's retry).
This commit is contained in:
2026-11-08 00:49:22 +01:00
parent e8b9bdd7c1
commit 470caacfb0
5 changed files with 66 additions and 10 deletions
+6 -4
View File
@@ -31,10 +31,12 @@ export const RECLAIM_WINDOW_MS = 120_000;
// less often, and says it cannot play only once the server has been away this long. It used to say
// so at the first 502, over a picture still playing from the buffer, and never asked again.
//
// The absence ends when MEDIA arrives again (a fragment loads), not when a session request is
// answered: a server that answers every session and delivers none of them is still not serving, and
// ending the absence at each answer restarted the stream forever. The same window bounds a link too
// slow for the stream (see `slowLink`).
// Under hls.js the absence ends when MEDIA arrives again (a fragment loads), not when a session
// request is answered: a server that answers every session and delivers none of them is still not
// serving, and ending the absence at each answer restarted the stream forever. What hls.js does not
// play (a direct file, HLS the browser plays natively) reports no fragments, so there the answer
// ends it (`answered`); and the viewer trying again after the error starts a window of its own
// (`start`). The same window bounds a link too slow for the stream (see `slowLink`).
export const AWAY_WINDOW_MS = 180_000;
const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000];
const SERVER_AWAY = new Set([0, 502, 503, 504]);
+8
View File
@@ -6,6 +6,7 @@ import {
buildStatRows,
createFragMeter,
fmtBitrate,
fmtBitrates,
fmtBufferSecs,
fmtClock,
fmtCodec,
@@ -28,6 +29,13 @@ describe("plexStats formatters", () => {
expect(fmtBitrate(4_200_000, "hu")).toBe("4,2 Mbps"); // the UI language's decimal mark
});
it("fmtBitrates says rates side by side in the unit the largest takes", () => {
expect(fmtBitrates([1_400_000, 800_000], "en")).toEqual(["1.4 Mbps", "0.8 Mbps"]);
expect(fmtBitrates([1_400_000, 800_000], "hu")).toEqual(["1,4 Mbps", "0,8 Mbps"]);
expect(fmtBitrates([900_000, 400_000], "en")).toEqual(["900 kbps", "400 kbps"]);
expect(fmtBitrates([2_000_000, 0], "en")).toEqual(["2.0 Mbps", DASH]);
});
it("fmtBufferSecs shows one decimal, dashes negatives/non-finite", () => {
expect(fmtBufferSecs(12.34, "en")).toBe("12.3 sec");
expect(fmtBufferSecs(0, "en")).toBe("0.0 sec");
+10
View File
@@ -64,6 +64,16 @@ export function fmtBitrate(bps: number | undefined, lang: string): string {
return `${formatNumber(Math.round(bps / 1000), lang)} kbps`;
}
// Rates read side by side ("needs 1.4 Mbps, 0.8 Mbps arrives"): one unit for all of them, the one
// the largest would take alone, so they compare at a glance — never "1.4 Mbps" beside "800 kbps".
export function fmtBitrates(rates: number[], lang: string): string[] {
const largest = Math.max(0, ...rates);
if (largest < 1_000_000) return rates.map((bps) => fmtBitrate(bps, lang));
return rates.map((bps) =>
bps > 0 ? `${formatDecimal(bps / 1_000_000, lang)} Mbps` : fmtBitrate(bps, lang),
);
}
// Seconds of buffer ahead of the playhead → "12.3 sec" / "12,3 mp".
export function fmtBufferSecs(s: number, lang: string): string {
if (!Number.isFinite(s) || s < 0) return DASH;
@@ -10,7 +10,12 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
// The slow line's rates are shown with the key: they are what its test reads.
useTranslation: () => ({
t: (key: string, o?: { need?: string; got?: string }) =>
key === "plex.player.slowLinkRates" ? `${key} ${o?.need} / ${o?.got}` : key,
i18n: { language: "en" },
}),
};
});
@@ -281,6 +286,37 @@ describe("an HLS segment that never arrives (a fatal 404)", () => {
});
describe("the slow-connection line", () => {
it("says both rates in one unit, so they compare at a glance", async () => {
vi.useFakeTimers({
shouldAdvanceTime: true,
toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"],
});
try {
const session = mount();
session.mockResolvedValueOnce({
mode: "hls",
url: "/hls/A/index.m3u8",
start_s: 600,
} as never);
await waitFor(() => expect(hlsInstances).toHaveLength(1));
// 1.05 MB of a 6 s fragment (1.4 Mbit/s) that took 10.5 s to arrive (0.8 Mbit/s): under a
// megabit, which on its own would read in kbps.
act(() =>
hlsInstances[0]!.emit(Hls.Events.FRAG_LOADED, {
frag: {
type: "main",
duration: 6,
stats: { total: 1_050_000, loading: { start: 0, first: 10, end: 10_500 } },
},
}),
);
act(() => void vi.advanceTimersByTime(5_100));
expect(screen.getByText("plex.player.slowLinkRates 1.4 Mbps / 0.8 Mbps")).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("measures only the stream being asked for, not the one before it", async () => {
// A restart that a direct file answers builds no hls.js, so a meter reset only there kept the
// slow HLS session's numbers on screen under the next stream.
+5 -5
View File
@@ -53,7 +53,7 @@ import ClipPanel, { ClipCropFrame, type ClipCrop } from "../../features/player/C
import { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter, fmtBitrate } from "../../lib/plexStats";
import { createFragMeter, fmtBitrates } from "../../lib/plexStats";
import { episodeLine } from "../../lib/plexDetail";
import { HlsEngine } from "../../lib/hlsEngine";
import { createHlsPort } from "../../lib/hlsPort";
@@ -309,6 +309,9 @@ export default function PlexPlayer({
const slowRates = useSlowLink((!ready || buffering) && !loadError, () =>
slowLinkRates(fragMeterRef.current.bitrate(), fragMeterRef.current.arrival()),
);
const [slowNeed, slowGot] = slowRates
? fmtBitrates([slowRates.need, slowRates.got], i18n.language)
: [];
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
const barAutoHideRef = useLatest(prefs.barAutoHide);
// The two language prefs are read only at ITEM LOAD (the detail effect below), so they go through
@@ -1352,10 +1355,7 @@ export default function PlexPlayer({
<>
<span>{t("plex.player.slowLink")}</span>
<span className="text-xs text-white/70">
{t("plex.player.slowLinkRates", {
need: fmtBitrate(slowRates.need, i18n.language),
got: fmtBitrate(slowRates.got, i18n.language),
})}
{t("plex.player.slowLinkRates", { need: slowNeed, got: slowGot })}
</span>
</>
) : !ready ? (