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
|
// 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.
|
// 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
|
// Under hls.js the absence ends when MEDIA arrives again (a fragment loads), not when a session
|
||||||
// answered: a server that answers every session and delivers none of them is still not serving, and
|
// request is answered: a server that answers every session and delivers none of them is still not
|
||||||
// ending the absence at each answer restarted the stream forever. The same window bounds a link too
|
// serving, and ending the absence at each answer restarted the stream forever. What hls.js does not
|
||||||
// slow for the stream (see `slowLink`).
|
// 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;
|
export const AWAY_WINDOW_MS = 180_000;
|
||||||
const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000];
|
const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000];
|
||||||
const SERVER_AWAY = new Set([0, 502, 503, 504]);
|
const SERVER_AWAY = new Set([0, 502, 503, 504]);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildStatRows,
|
buildStatRows,
|
||||||
createFragMeter,
|
createFragMeter,
|
||||||
fmtBitrate,
|
fmtBitrate,
|
||||||
|
fmtBitrates,
|
||||||
fmtBufferSecs,
|
fmtBufferSecs,
|
||||||
fmtClock,
|
fmtClock,
|
||||||
fmtCodec,
|
fmtCodec,
|
||||||
@@ -28,6 +29,13 @@ describe("plexStats formatters", () => {
|
|||||||
expect(fmtBitrate(4_200_000, "hu")).toBe("4,2 Mbps"); // the UI language's decimal mark
|
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", () => {
|
it("fmtBufferSecs shows one decimal, dashes negatives/non-finite", () => {
|
||||||
expect(fmtBufferSecs(12.34, "en")).toBe("12.3 sec");
|
expect(fmtBufferSecs(12.34, "en")).toBe("12.3 sec");
|
||||||
expect(fmtBufferSecs(0, "en")).toBe("0.0 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`;
|
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".
|
// Seconds of buffer ahead of the playhead → "12.3 sec" / "12,3 mp".
|
||||||
export function fmtBufferSecs(s: number, lang: string): string {
|
export function fmtBufferSecs(s: number, lang: string): string {
|
||||||
if (!Number.isFinite(s) || s < 0) return DASH;
|
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")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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", () => {
|
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 () => {
|
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
|
// 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.
|
// 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 { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange";
|
||||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||||
import { createFragMeter, fmtBitrate } from "../../lib/plexStats";
|
import { createFragMeter, fmtBitrates } from "../../lib/plexStats";
|
||||||
import { episodeLine } from "../../lib/plexDetail";
|
import { episodeLine } from "../../lib/plexDetail";
|
||||||
import { HlsEngine } from "../../lib/hlsEngine";
|
import { HlsEngine } from "../../lib/hlsEngine";
|
||||||
import { createHlsPort } from "../../lib/hlsPort";
|
import { createHlsPort } from "../../lib/hlsPort";
|
||||||
@@ -309,6 +309,9 @@ export default function PlexPlayer({
|
|||||||
const slowRates = useSlowLink((!ready || buffering) && !loadError, () =>
|
const slowRates = useSlowLink((!ready || buffering) && !loadError, () =>
|
||||||
slowLinkRates(fragMeterRef.current.bitrate(), fragMeterRef.current.arrival()),
|
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).
|
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
|
||||||
const barAutoHideRef = useLatest(prefs.barAutoHide);
|
const barAutoHideRef = useLatest(prefs.barAutoHide);
|
||||||
// The two language prefs are read only at ITEM LOAD (the detail effect below), so they go through
|
// 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>{t("plex.player.slowLink")}</span>
|
||||||
<span className="text-xs text-white/70">
|
<span className="text-xs text-white/70">
|
||||||
{t("plex.player.slowLinkRates", {
|
{t("plex.player.slowLinkRates", { need: slowNeed, got: slowGot })}
|
||||||
need: fmtBitrate(slowRates.need, i18n.language),
|
|
||||||
got: fmtBitrate(slowRates.got, i18n.language),
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : !ready ? (
|
) : !ready ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user