fix(plex): a link too slow for the stream resumes in place, and says so

A request hls.js got no answer to in time (no status) was taken for a server
away: a new session, the same first segment lost again, a restart every half
minute that never ended, since every session answer reset the away window.
Measured on prod: a work network at ~4 Mbit/s against 11-16 MB remux segments.

A timeout now resumes loading in the same session; the away window ends only
when media arrives (or a direct stream answers), and runs out in a 'too slow'
error. A segment gets 30 s to its first byte. After 5 s of stalling on a link
measured slower than the stream, the player says so with both rates.
This commit is contained in:
2026-11-07 15:09:22 +01:00
parent 25aed6635f
commit c0682aa572
13 changed files with 495 additions and 30 deletions
+3
View File
@@ -191,6 +191,9 @@
"errNotFound": "This file can't be played — the media isn't reachable on the server (the Plex media mount is missing or the path map is wrong). Ask the admin to check the Plex configuration.",
"errTranscode": "This file's format needs transcoding, which isn't supported yet.",
"errGeneric": "Playback couldn't start. Please try again.",
"errSlow": "Couldn't play: the connection is too slow for this file",
"slowLink": "Slow connection",
"slowLinkRates": "This file needs about {{need}}, about {{got}} is arriving",
"help": "Keyboard shortcuts",
"infoBtn": "Media info",
"more": "More",
+3
View File
@@ -191,6 +191,9 @@
"errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.",
"errTranscode": "Ennek a fájlnak a formátuma átkódolást igényel, ami még nem támogatott.",
"errGeneric": "Nem sikerült elindítani a lejátszást. Próbáld újra.",
"errSlow": "Nem sikerült lejátszani: a kapcsolat túl lassú ehhez a fájlhoz",
"slowLink": "Lassú a kapcsolat",
"slowLinkRates": "A fájlhoz kb. {{need}} kell, most kb. {{got}} érkezik",
"help": "Billentyűparancsok",
"infoBtn": "Média infó",
"more": "Továbbiak",
+9
View File
@@ -6,6 +6,7 @@ import {
formatCompact,
formatCountOrDash,
formatDecimal,
formatMbps,
formatMoney,
formatDate,
formatDateTime,
@@ -284,3 +285,11 @@ describe("numbers in the UI language", () => {
expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en"));
});
});
describe("formatMbps", () => {
it("writes a rate in the browser's own unit, a decimal only under 10", () => {
expect(formatMbps(19_600_000, "en")).toBe("20 Mb/s");
expect(formatMbps(4_130_000, "en")).toBe("4.1 Mb/s");
expect(formatMbps(4_130_000, "hu")).toBe("4,1 Mb/mp");
});
});
+14
View File
@@ -133,6 +133,20 @@ export function formatUnit(
);
}
/** A rate in megabits per second, in the browser's own short form ("20 Mb/s" / "20 Mb/mp"): one
* decimal under 10, whole above. */
export function formatMbps(bitsPerSecond: number, lang: string): string {
const mbps = bitsPerSecond / 1_000_000;
const digits = mbps < 10 ? 1 : 0;
return numberFormat(lang, {
style: "unit",
unit: "megabit-per-second",
unitDisplay: "short",
maximumFractionDigits: digits,
minimumFractionDigits: digits,
}).format(mbps);
}
/** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats
* BOTH a single item's length (Plex movies/episodes) and an aggregate total (playlist playtime, on
* the YouTube and the Plex side alike) — it is the one renderer for a playlist total, so a total
+110
View File
@@ -46,6 +46,7 @@ class FakeHls implements HlsPort {
loaded: string | null = null;
destroyed = false;
audioTrack = 0;
resumes = 0;
constructor(readonly handlers: HlsHandlers) {}
load(url: string) {
this.loaded = url;
@@ -53,6 +54,24 @@ class FakeHls implements HlsPort {
destroy() {
this.destroyed = true;
}
resume() {
this.resumes++;
}
/** hls.js gave up on a request that never answered in time: no response, so no status. */
timedOut(details = "fragLoadTimeOut") {
this.handlers.onError({
fatal: true,
network: true,
status: null,
timeout: true,
type: "networkError",
details,
});
}
/** A fragment arrived: media is getting through. */
arrived() {
this.handlers.onFragLoaded({ type: "main", bytes: 1_000_000, durationS: 6 });
}
getAudioTrack() {
return this.audioTrack;
}
@@ -72,6 +91,7 @@ class FakeHls implements HlsPort {
fatal: false,
network: true,
status: 410,
timeout: false,
type: "networkError",
details: "fragLoadError",
});
@@ -82,6 +102,7 @@ class FakeHls implements HlsPort {
fatal: false,
network: true,
status,
timeout: false,
type: "networkError",
details: "fragLoadError",
});
@@ -92,6 +113,7 @@ class FakeHls implements HlsPort {
fatal: true,
network: true,
status,
timeout: false,
type: "networkError",
details: "levelLoadError",
});
@@ -579,6 +601,7 @@ describe("a server that is restarting (a deploy)", () => {
fire();
await asks[1]!.resolve(hls("A"));
hlses[1]!.handlers.onManifestParsed();
hlses[1]!.arrived(); // back: media gets through again
advance(AWAY_WINDOW_MS + 1);
hlses[1]!.lost(502); // a second deploy, long after the first
expect(engine.getSnapshot().error).toBeNull();
@@ -608,6 +631,93 @@ describe("a server that is restarting (a deploy)", () => {
hlses[0]!.lost(500);
expect(engine.getSnapshot().error).toBe("generic");
});
it("takes a direct stream's answer for the server being back: it has no fragments to show it", async () => {
const { engine, asks, fire, advance } = setup();
engine.start("A", 0);
await asks[0]!.reject({ status: 502 });
fire();
await asks[1]!.resolve(direct("A"));
advance(AWAY_WINDOW_MS + 1);
engine.start("A", 100); // a second deploy, long after the first
await asks[2]!.reject({ status: 502 });
expect(engine.getSnapshot().error).toBeNull();
});
it("ends in an error when the server answers every session but none of them delivers", async () => {
// Every answer used to start the absence over, so this asked for a new session forever.
const { engine, asks, hlses, fire } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
hlses[0]!.lost(0);
for (let i = 1; i < 40 && fire(); i++) {
await asks[i]!.resolve(hls("A", 0, `s-${i}`));
hlses[i]!.handlers.onManifestParsed();
hlses[i]!.lost(0);
}
expect(engine.getSnapshot().error).toBe("generic");
expect(asks.length).toBeLessThan(40);
});
});
describe("a link too slow for the stream", () => {
// Measured on prod (2026-11-04, a work network at ~4 Mbit/s against 11–16 MB remux segments): an
// audio segment waited behind a video one past hls.js's time to first byte, five times, and gave up
// with no answer. That was taken for a server away: a new session, the same segment lost again, a
// restart every half minute until the viewer closed the player.
it("resumes loading in the same session, and asks for no new one", async () => {
const { engine, asks, hlses, timers } = setup();
engine.start("A", 453);
await asks[0]!.resolve(hls("A", 453));
hlses[0]!.handlers.onManifestParsed();
hlses[0]!.timedOut();
expect(asks).toHaveLength(1);
expect(timers.filter((t) => t.armed)).toHaveLength(0);
expect(hlses[0]!.resumes).toBe(1);
expect(hlses[0]!.destroyed).toBe(false);
expect(engine.getSnapshot()).toMatchObject({ ready: true, error: null });
});
it.each(["fragLoadTimeOut", "levelLoadTimeOut", "audioTrackLoadTimeOut"])(
"treats a %s the same way",
async (details) => {
const { engine, asks, hlses } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.timedOut(details);
expect(asks).toHaveLength(1);
expect(hlses[0]!.resumes).toBe(1);
},
);
it("keeps going for as long as media still arrives, however slowly", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
for (let i = 0; i < 10; i++) {
hlses[0]!.timedOut();
advance(60_000);
hlses[0]!.arrived();
}
hlses[0]!.timedOut();
expect(engine.getSnapshot().error).toBeNull();
expect(asks).toHaveLength(1);
});
it("says the connection is too slow once nothing has arrived for the whole window", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
hlses[0]!.timedOut();
advance(AWAY_WINDOW_MS + 1);
hlses[0]!.timedOut();
expect(engine.getSnapshot().error).toBe("slow");
expect(hlses[0]!.resumes).toBe(1);
expect(asks).toHaveLength(1);
});
});
describe("play intent while a session is asked for", () => {
+51 -6
View File
@@ -30,14 +30,20 @@ export const RECLAIM_WINDOW_MS = 120_000;
// gone with it, but the buffer keeps playing — so the engine asks again where the viewer is, less and
// 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`).
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]);
// The server's answer for a session it no longer has; its 404 means a segment not there yet.
const SESSION_GONE = 410;
/** Why a session could not play, in the three buckets the player has a sentence for. */
type EngineError = "transcode" | "notFound" | "generic";
/** Why a session could not play, in the buckets the player has a sentence for. `slow`: the link
* never delivered the stream in time for the whole away window. */
type EngineError = "transcode" | "notFound" | "slow" | "generic";
/** What the player renders from the engine. A new object whenever any of it changes. */
export interface EngineSnapshot {
@@ -55,14 +61,27 @@ export interface HlsFailure {
fatal: boolean;
network: boolean;
status: number | null;
/** A request that got no answer in time (no response, so `status` is null). */
timeout: boolean;
type: string;
details: string;
}
/** A loaded fragment, as the meters read it. `loading`: when its download started and ended
* (performance.now() ms), when known. */
export interface FragLoaded {
type: string;
bytes: number;
durationS: number;
loading?: { start: number; end: number };
}
/** One hls.js instance, narrowed to what the engine uses (hlsPort.ts binds the real one). */
export interface HlsPort {
load(url: string, media: HTMLMediaElement): void;
destroy(): void;
/** Start loading again where it stopped, after a fatal network error (same session). */
resume(): void;
getAudioTrack(): number;
setAudioTrack(track: number): void;
levelInfo(): HlsLevelInfo;
@@ -71,7 +90,7 @@ export interface HlsPort {
export interface HlsHandlers {
onManifestParsed(): void;
onAudioTracksUpdated(): void;
onFragLoaded(frag: { type: string; bytes: number; durationS: number }): void;
onFragLoaded(frag: FragLoaded): void;
onError(e: HlsFailure): void;
}
@@ -106,7 +125,7 @@ export interface EngineDeps {
statusOf(e: unknown): number | undefined;
/** A request for a session went out: the player clears its stall spinner. */
onLoadStart?(): void;
onFragLoaded?(frag: { type: string; bytes: number; durationS: number }): void;
onFragLoaded?(frag: FragLoaded): void;
onHlsError?(e: HlsFailure): void;
}
@@ -358,7 +377,9 @@ export class HlsEngine {
}
const media = this.deps.media();
if (!media) return;
this.away = null; // the server answered: a later absence is a new one
// A direct stream reports no fragments: its answer is the only sign the server is back. An HLS
// absence ends when a fragment arrives (see `AWAY_WINDOW_MS`).
if (sess.mode !== "hls") this.away = null;
const { intent, startAt, id } = req;
// The stream that kept playing is replaced from here: the new one loads like any other.
if (req.quiet) this.set({ ready: false });
@@ -456,7 +477,10 @@ export class HlsEngine {
/* out of range — ignore */
}
},
onFragLoaded: (frag) => this.deps.onFragLoaded?.(frag),
onFragLoaded: (frag) => {
if (this.current(req)) this.away = null; // media gets through: whatever was away is back
this.deps.onFragLoaded?.(frag);
},
onError: (e) => this.onHlsError(req, media, e),
};
}
@@ -489,6 +513,10 @@ export class HlsEngine {
return;
}
if (!e.fatal) return;
if (e.network && e.timeout) {
this.slowLink();
return;
}
if (e.network && SERVER_AWAY.has(e.status ?? 0) && this.serverAway(req)) return;
if (e.network && e.status === 404) {
this.reclaim(req, media, false);
@@ -538,6 +566,23 @@ export class HlsEngine {
return true;
}
/** A request got no answer in time: the link is too slow for what the stream asks of it, not the
* server away (measured on prod: a work network at ~4 Mbit/s against 11–16 MB remux segments; an
* audio segment waited behind a video one past hls.js's time to first byte). A new session would
* throw away what had arrived and lose its first segment the same way — a restart every half
* minute, never playing. So loading resumes in the same session, for as long as media still
* arrives; the away window says it is too slow once nothing has for that long. */
private slowLink(): void {
const now = this.deps.now();
const away = (this.away ??= { since: now, tries: 0 });
if (now - away.since > AWAY_WINDOW_MS) {
this.set({ error: "slow" });
return;
}
away.tries += 1;
this.hls?.resume();
}
/** Where the viewer is: the ticked position, or — before playback has ticked (a session still
* loading, or a resumed one that has stayed paused) — where the session was ASKED to start.
* Anything that restarts "here" must use this: reading the ticked 0 restarted a resume at 20:00
+97 -15
View File
@@ -1,42 +1,124 @@
// The one place hls.js is built for the Plex player, held against a stand-in hls.js: what config it is
// built with, and what it hands the engine from hls.js's events.
//
// hls.js empties the cues of every text track on the <video> when a manifest loads or the media
// attaches — the Plex player's own subtitle <track> included, so a VTT that loaded first showed
// nothing for the rest of the session. Its subtitle and caption controllers are not built at all.
import { describe, expect, it, vi } from "vitest";
import type { FragLoaded, HlsFailure } from "./hlsEngine";
const built: Record<string, unknown>[] = [];
type Listener = (e: string, data: unknown) => void;
interface Built {
config: Record<string, unknown>;
on: Record<string, Listener>;
starts: number[];
media: { currentTime: number } | null;
}
const built: Built[] = [];
vi.mock("hls.js", () => {
class FakeHls {
static isSupported = () => true;
static Events = {};
static ErrorTypes = {};
static Events = {
MANIFEST_PARSED: "hlsManifestParsed",
AUDIO_TRACKS_UPDATED: "hlsAudioTracksUpdated",
FRAG_LOADED: "hlsFragLoaded",
ERROR: "hlsError",
};
static ErrorTypes = { NETWORK_ERROR: "networkError" };
readonly self: Built;
constructor(config: Record<string, unknown>) {
built.push(config);
this.self = { config, on: {}, starts: [], media: null };
built.push(this.self);
}
get media() {
return this.self.media;
}
on(event: string, fn: Listener) {
this.self.on[event] = fn;
}
startLoad(at: number) {
this.self.starts.push(at);
}
on() {}
}
return { default: FakeHls };
});
const { createHlsPort } = await import("./hlsPort");
function build() {
const frags: FragLoaded[] = [];
const errors: HlsFailure[] = [];
const noop = () => {};
const port = createHlsPort({
onManifestParsed: noop,
onAudioTracksUpdated: noop,
onFragLoaded: (f) => void frags.push(f),
onError: (e) => void errors.push(e),
})!;
return { port, hls: built[built.length - 1]!, frags, errors };
}
describe("createHlsPort", () => {
it("builds hls.js without the controllers that clear every text track's cues", () => {
const noop = () => {};
createHlsPort({
onManifestParsed: noop,
onAudioTracksUpdated: noop,
onFragLoaded: noop,
onError: noop,
});
const config = built[0] ?? {};
const { hls } = build();
for (const key of [
"subtitleTrackController",
"subtitleStreamController",
"timelineController",
]) {
// Set, as undefined: hls.js spreads the config over its defaults, so an absent key keeps them.
expect(key in config).toBe(true);
expect(config[key]).toBeUndefined();
expect(key in hls.config).toBe(true);
expect(hls.config[key]).toBeUndefined();
}
});
it("gives a fragment 30 s to its first byte: a small audio one waits behind a big remux one", () => {
const { hls } = build();
const policy = hls.config.fragLoadPolicy as { default: { maxTimeToFirstByteMs: number } };
expect(policy.default.maxTimeToFirstByteMs).toBe(30_000);
});
it("names a request that got no answer in time a timeout, and an answered failure not", () => {
const { hls, errors } = build();
const err = (details: string, code?: number) =>
hls.on.hlsError!("hlsError", {
fatal: true,
type: "networkError",
details,
response: code ? { code } : undefined,
});
err("fragLoadTimeOut");
err("levelLoadTimeOut");
err("fragLoadError", 404);
expect(errors.map((e) => [e.timeout, e.status])).toEqual([
[true, null],
[true, null],
[false, 404],
]);
});
it("hands on when a fragment's download ran", () => {
const { hls, frags } = build();
const frag = (loading?: { start: number; end: number }) =>
hls.on.hlsFragLoaded!("hlsFragLoaded", {
frag: { type: "main", duration: 6, stats: { total: 16_000_000, loading } },
});
frag({ start: 1000, end: 32_200 });
frag(undefined);
frag({ start: 0, end: 0 }); // not timed
expect(frags.map((f) => f.loading)).toEqual([
{ start: 1000, end: 32_200 },
undefined,
undefined,
]);
});
it("resumes loading where the media is — at 0 for one that never ticked, not the live edge", () => {
const { port, hls } = build();
hls.media = { currentTime: 0 };
port.resume();
hls.media = { currentTime: 312.5 };
port.resume();
expect(hls.starts).toEqual([0, 312.5]);
});
});
+26 -4
View File
@@ -17,10 +17,23 @@ export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
// a <track> of ours, out of band, and hls.js empties the cues of EVERY text track on the element
// when a manifest loads or the media attaches/detaches — ours included, so a VTT that loaded before
// the manifest showed nothing for the rest of the session.
//
// A fragment gets 30 s to its first byte, not hls.js's 10: a remux segment is the source's own
// bitrate (11–16 MB measured on prod), and on a slow link (a work network at ~4 Mbit/s) the small
// audio segment beside it waits behind it longer than 10 s — five such waits ended the load. The
// rest is hls.js's default policy.
const hls = new Hls({
maxBufferLength: 30,
enableWorker: true,
startPosition: 0,
fragLoadPolicy: {
default: {
maxTimeToFirstByteMs: 30_000,
maxLoadTimeMs: 120_000,
timeoutRetry: { maxNumRetry: 4, retryDelayMs: 0, maxRetryDelayMs: 0 },
errorRetry: { maxNumRetry: 6, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
},
},
subtitleTrackController: undefined,
subtitleStreamController: undefined,
timelineController: undefined,
@@ -28,19 +41,24 @@ export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
hls.on(Hls.Events.MANIFEST_PARSED, () => handlers.onManifestParsed());
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
// The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a
// measurement (see plexStats.ts).
hls.on(Hls.Events.FRAG_LOADED, (_e, data) =>
// measurement (see plexStats.ts). When its download ran says how fast the link delivers.
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
const l = data.frag.stats?.loading;
handlers.onFragLoaded({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
durationS: data.frag.duration,
}),
);
loading: l && l.end > l.start ? { start: l.start, end: l.end } : undefined,
});
});
hls.on(Hls.Events.ERROR, (_e, data) =>
handlers.onError({
fatal: data.fatal,
network: data.type === Hls.ErrorTypes.NETWORK_ERROR,
status: data.response?.code ?? null,
// hls.js names every no-answer-in-time failure `…TimeOut` (fragLoadTimeOut, levelLoadTimeOut,
// audioTrackLoadTimeOut, manifestLoadTimeOut, …).
timeout: data.details.endsWith("TimeOut"),
type: data.type,
details: data.details,
}),
@@ -51,6 +69,10 @@ export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
hls.attachMedia(media);
},
destroy: () => hls.destroy(),
// hls.js's own recovery after a fatal network error: load again from where the media is. The
// position is passed: with none, a session that never ticked would start at the live edge (see
// `startPosition: 0` above).
resume: () => hls.startLoad(hls.media?.currentTime ?? 0),
getAudioTrack: () => hls.audioTrack,
setAudioTrack(track) {
hls.audioTrack = track;
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
//
// The player says the link is too slow only when a stall has lasted and the measured arrival rate is
// below what the stream needs (measured on prod: ~4 Mbit/s arriving against a ~20 Mbit/s remux).
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SLOW_LINK_AFTER_MS, slowLinkRates, useSlowLink, type LinkRates } from "./plexPlayerHooks";
describe("slowLinkRates", () => {
it("names both rates only when the link delivers slower than the stream needs", () => {
expect(slowLinkRates(20e6, 4e6)).toEqual({ need: 20e6, got: 4e6 });
expect(slowLinkRates(20e6, 25e6)).toBeNull();
});
it("says nothing before both are measured", () => {
expect(slowLinkRates(undefined, 4e6)).toBeNull();
expect(slowLinkRates(20e6, undefined)).toBeNull();
});
});
describe("useSlowLink", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const slow: LinkRates = { need: 20e6, got: 4e6 };
it("shows nothing for a short stall, the rates once it has lasted", () => {
const { result } = renderHook(({ stalled }) => useSlowLink(stalled, () => slow), {
initialProps: { stalled: true },
});
act(() => void vi.advanceTimersByTime(SLOW_LINK_AFTER_MS - 1));
expect(result.current).toBeNull();
act(() => void vi.advanceTimersByTime(1));
expect(result.current).toEqual(slow);
});
it("follows the measurement while the stall lasts", () => {
let rates: LinkRates | null = slow;
const { result } = renderHook(() => useSlowLink(true, () => rates));
act(() => void vi.advanceTimersByTime(SLOW_LINK_AFTER_MS));
expect(result.current).toEqual(slow);
rates = null; // the link caught up
act(() => void vi.advanceTimersByTime(2000));
expect(result.current).toBeNull();
});
it("goes when the stall ends, and the next stall waits its own 5 s", () => {
const { result, rerender } = renderHook(({ stalled }) => useSlowLink(stalled, () => slow), {
initialProps: { stalled: true },
});
act(() => void vi.advanceTimersByTime(SLOW_LINK_AFTER_MS));
expect(result.current).toEqual(slow);
rerender({ stalled: false });
expect(result.current).toBeNull();
rerender({ stalled: true });
expect(result.current).toBeNull();
act(() => void vi.advanceTimersByTime(SLOW_LINK_AFTER_MS));
expect(result.current).toEqual(slow);
});
});
+44
View File
@@ -1,6 +1,7 @@
import {
useCallback,
useEffect,
useEffectEvent,
useRef,
useState,
type RefObject,
@@ -47,6 +48,49 @@ export function useWallClock(): Date {
return now;
}
/** How long a stall lasts before the player says the link is too slow: a short rebuffer is ordinary. */
export const SLOW_LINK_AFTER_MS = 5000;
export interface LinkRates {
/** Bits per second the stream needs (its own bitrate, measured from what arrived). */
need: number;
/** Bits per second the link delivered it at. */
got: number;
}
/** Both rates when the link delivers slower than the stream needs; null when it keeps up, or before
* anything has been measured — the player never says "slow" without a number behind it. */
export function slowLinkRates(need: number | undefined, got: number | undefined): LinkRates | null {
return need && got && got < need ? { need, got } : null;
}
/** The slow-link rates to show while `stalled` (loading, or rebuffering mid-play) has lasted
* `SLOW_LINK_AFTER_MS`, re-read every 2 s while it lasts; null otherwise. `read` is called from the
* timers only (it reads the fragment meter, a ref). */
export function useSlowLink(stalled: boolean, read: () => LinkRates | null): LinkRates | null {
const [rates, setRates] = useState<LinkRates | null>(null);
// Every stall starts from nothing: what the previous one measured is not shown before its 5 s.
const [wasStalled, setWasStalled] = useState(stalled);
if (wasStalled !== stalled) {
setWasStalled(stalled);
setRates(null);
}
const readNow = useEffectEvent(() => setRates(read()));
useEffect(() => {
if (!stalled) return;
let iv = 0;
const t = window.setTimeout(() => {
readNow();
iv = window.setInterval(readNow, 2000);
}, SLOW_LINK_AFTER_MS);
return () => {
window.clearTimeout(t);
window.clearInterval(iv);
};
}, [stalled]);
return stalled ? rates : null;
}
// Fullscreen toggle for `el` (the player wrapper) + a reactive `fs` flag.
// Via lib/fullscreen, not the raw API: WebKit (Safari/iPadOS) ships only the prefixed spelling, so
// reading `document.fullscreenElement` alone made this player believe it is never fullscreen — the
+32
View File
@@ -180,6 +180,38 @@ describe("measured bitrate", () => {
expect(m.bitrate()).toBe(8_000_000 + 200_000);
});
it("measures how fast the bytes arrive, beside how fast the stream needs them", () => {
// The prod case: a 16 MB, 6 s video segment that took 31.2 s to come through.
const m = createFragMeter();
m.add({ type: "main", bytes: 16_000_000, durationS: 6, loading: { start: 0, end: 31_200 } });
expect(m.bitrate()).toBeCloseTo((16_000_000 * 8) / 6);
expect(m.arrival()).toBeCloseTo((16_000_000 * 8) / 31.2);
});
it("counts downloads that overlap once: the renditions share one link", () => {
// Measured live: summing the per-rendition rates read 2.3 Mbit/s through a 1.6 Mbit/s link.
const m = createFragMeter();
m.add({ type: "main", bytes: 1_800_000, durationS: 4, loading: { start: 0, end: 10_000 } });
m.add({ type: "audio", bytes: 100_000, durationS: 4, loading: { start: 2_000, end: 8_000 } });
m.add({ type: "audio", bytes: 100_000, durationS: 4, loading: { start: 9_000, end: 12_000 } });
m.add({
type: "main",
bytes: 2_000_000,
durationS: 4,
loading: { start: 15_000, end: 25_000 },
});
// 4 MB over the 22 s something was downloading (0–12 s and 15–25 s): not the 29 s the downloads
// add up to, nor per type.
expect(m.arrival()).toBeCloseTo((4_000_000 * 8) / 22);
});
it("has no arrival rate for fragments whose download time is unknown", () => {
const m = createFragMeter();
m.add({ type: "main", bytes: 6_000_000, durationS: 6 });
expect(m.bitrate()).toBe(8_000_000);
expect(m.arrival()).toBeUndefined();
});
it("has nothing to say before the first fragment, and after a reset", () => {
const m = createFragMeter();
expect(m.bitrate()).toBeUndefined(); // an honest dash — never the manifest's stand-in number
+22
View File
@@ -188,12 +188,18 @@ interface FragSample {
type: string; // hls.js fragment type: "main" (video, and muxed audio) / "audio" / "subtitle"
bytes: number;
durationS: number; // MEDIA seconds the fragment carries (not how long it took to download)
loading?: { start: number; end: number }; // when its download ran (ms), when hls.js knew
}
export interface FragMeter {
add(sample: FragSample): void;
/** Bits per second of media, or undefined while nothing has been measured yet. */
bitrate(): number | undefined;
/** Bits per second the link delivered them at, or undefined while no download time is known: every
* timed fragment's bytes over the time ANY of them was downloading. Not per type and summed like
* `bitrate` — the renditions share one link, and their downloads overlap (measured: summing read
* 2.3 Mbit/s through a 1.6 Mbit/s link). */
arrival(): number | undefined;
reset(): void;
}
@@ -222,6 +228,22 @@ export function createFragMeter(window = 10): FragMeter {
}
return total > 0 ? total : undefined;
},
arrival() {
const timed = [...byType.values()]
.flat()
.filter((f) => f.loading && f.loading.end > f.loading.start)
.sort((a, b) => a.loading!.start - b.loading!.start);
let busyMs = 0;
let until = -Infinity;
for (const { loading } of timed) {
const { start, end } = loading!;
if (end <= until) continue;
busyMs += end - Math.max(start, until);
until = end;
}
const bytes = timed.reduce((n, f) => n + f.bytes, 0);
return busyMs > 0 ? (bytes * 8) / (busyMs / 1000) : undefined;
},
reset() {
byType.clear();
},
+24 -5
View File
@@ -70,7 +70,9 @@ import {
usePlayerVolume,
usePlexProgress,
usePlexTracks,
useSlowLink,
useWallClock,
slowLinkRates,
} from "../../lib/plexPlayerHooks";
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
import {
@@ -101,7 +103,7 @@ import { MenuCheck } from "../../components/ui/MenuCheck";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { Slider } from "../../components/ui/NativeControls";
import { formatUnit } from "../../lib/format";
import { formatMbps, formatUnit } from "../../lib/format";
import { trackLabel } from "../../lib/vocab";
import { msg } from "../../lib/notifications";
@@ -286,9 +288,16 @@ export default function PlexPlayer({
? t("plex.player.errTranscode")
: engineError === "notFound"
? t("plex.player.errNotFound")
: engineError
? t("plex.player.errGeneric")
: null;
: engineError === "slow"
? t("plex.player.errSlow")
: engineError
? t("plex.player.errGeneric")
: null;
// A stall that lasts, on a link measured slower than the stream: said under the spinner, with both
// numbers, so the viewer can tell their connection from a broken player.
const slowRates = useSlowLink((!ready || buffering) && !loadError, () =>
slowLinkRates(fragMeterRef.current.bitrate(), fragMeterRef.current.arrival()),
);
// 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
@@ -1326,7 +1335,17 @@ export default function PlexPlayer({
{/* Announce the stall to assistive tech. Initial load shows visible text; a mid-playback
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
{!ready ? (
{slowRates ? (
<>
<span>{t("plex.player.slowLink")}</span>
<span className="text-xs text-white/60">
{t("plex.player.slowLinkRates", {
need: formatMbps(slowRates.need, i18n.language),
got: formatMbps(slowRates.got, i18n.language),
})}
</span>
</>
) : !ready ? (
<span>
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
</span>