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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user