diff --git a/frontend/src/lib/hlsEngine.ts b/frontend/src/lib/hlsEngine.ts index 89b91a53..42308f21 100644 --- a/frontend/src/lib/hlsEngine.ts +++ b/frontend/src/lib/hlsEngine.ts @@ -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]); diff --git a/frontend/src/lib/plexStats.test.ts b/frontend/src/lib/plexStats.test.ts index 9be7eb63..6e58b388 100644 --- a/frontend/src/lib/plexStats.test.ts +++ b/frontend/src/lib/plexStats.test.ts @@ -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"); diff --git a/frontend/src/lib/plexStats.ts b/frontend/src/lib/plexStats.ts index b1000e7d..3c5fb3c8 100644 --- a/frontend/src/lib/plexStats.ts +++ b/frontend/src/lib/plexStats.ts @@ -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; diff --git a/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx index 215097d4..33d6097d 100644 --- a/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx @@ -10,7 +10,12 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); 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. diff --git a/frontend/src/modules/plex/PlexPlayer.tsx b/frontend/src/modules/plex/PlexPlayer.tsx index a43e98d7..575a4e11 100644 --- a/frontend/src/modules/plex/PlexPlayer.tsx +++ b/frontend/src/modules/plex/PlexPlayer.tsx @@ -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({ <> {t("plex.player.slowLink")} - {t("plex.player.slowLinkRates", { - need: fmtBitrate(slowRates.need, i18n.language), - got: fmtBitrate(slowRates.got, i18n.language), - })} + {t("plex.player.slowLinkRates", { need: slowNeed, got: slowGot })} ) : !ready ? (