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:
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user