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.",
|
"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.",
|
"errTranscode": "This file's format needs transcoding, which isn't supported yet.",
|
||||||
"errGeneric": "Playback couldn't start. Please try again.",
|
"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",
|
"help": "Keyboard shortcuts",
|
||||||
"infoBtn": "Media info",
|
"infoBtn": "Media info",
|
||||||
"more": "More",
|
"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.",
|
"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.",
|
"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.",
|
"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",
|
"help": "Billentyűparancsok",
|
||||||
"infoBtn": "Média infó",
|
"infoBtn": "Média infó",
|
||||||
"more": "Továbbiak",
|
"more": "Továbbiak",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
formatCompact,
|
formatCompact,
|
||||||
formatCountOrDash,
|
formatCountOrDash,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
|
formatMbps,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
@@ -284,3 +285,11 @@ describe("numbers in the UI language", () => {
|
|||||||
expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en"));
|
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
|
/** 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
|
* 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
|
* 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;
|
loaded: string | null = null;
|
||||||
destroyed = false;
|
destroyed = false;
|
||||||
audioTrack = 0;
|
audioTrack = 0;
|
||||||
|
resumes = 0;
|
||||||
constructor(readonly handlers: HlsHandlers) {}
|
constructor(readonly handlers: HlsHandlers) {}
|
||||||
load(url: string) {
|
load(url: string) {
|
||||||
this.loaded = url;
|
this.loaded = url;
|
||||||
@@ -53,6 +54,24 @@ class FakeHls implements HlsPort {
|
|||||||
destroy() {
|
destroy() {
|
||||||
this.destroyed = true;
|
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() {
|
getAudioTrack() {
|
||||||
return this.audioTrack;
|
return this.audioTrack;
|
||||||
}
|
}
|
||||||
@@ -72,6 +91,7 @@ class FakeHls implements HlsPort {
|
|||||||
fatal: false,
|
fatal: false,
|
||||||
network: true,
|
network: true,
|
||||||
status: 410,
|
status: 410,
|
||||||
|
timeout: false,
|
||||||
type: "networkError",
|
type: "networkError",
|
||||||
details: "fragLoadError",
|
details: "fragLoadError",
|
||||||
});
|
});
|
||||||
@@ -82,6 +102,7 @@ class FakeHls implements HlsPort {
|
|||||||
fatal: false,
|
fatal: false,
|
||||||
network: true,
|
network: true,
|
||||||
status,
|
status,
|
||||||
|
timeout: false,
|
||||||
type: "networkError",
|
type: "networkError",
|
||||||
details: "fragLoadError",
|
details: "fragLoadError",
|
||||||
});
|
});
|
||||||
@@ -92,6 +113,7 @@ class FakeHls implements HlsPort {
|
|||||||
fatal: true,
|
fatal: true,
|
||||||
network: true,
|
network: true,
|
||||||
status,
|
status,
|
||||||
|
timeout: false,
|
||||||
type: "networkError",
|
type: "networkError",
|
||||||
details: "levelLoadError",
|
details: "levelLoadError",
|
||||||
});
|
});
|
||||||
@@ -579,6 +601,7 @@ describe("a server that is restarting (a deploy)", () => {
|
|||||||
fire();
|
fire();
|
||||||
await asks[1]!.resolve(hls("A"));
|
await asks[1]!.resolve(hls("A"));
|
||||||
hlses[1]!.handlers.onManifestParsed();
|
hlses[1]!.handlers.onManifestParsed();
|
||||||
|
hlses[1]!.arrived(); // back: media gets through again
|
||||||
advance(AWAY_WINDOW_MS + 1);
|
advance(AWAY_WINDOW_MS + 1);
|
||||||
hlses[1]!.lost(502); // a second deploy, long after the first
|
hlses[1]!.lost(502); // a second deploy, long after the first
|
||||||
expect(engine.getSnapshot().error).toBeNull();
|
expect(engine.getSnapshot().error).toBeNull();
|
||||||
@@ -608,6 +631,93 @@ describe("a server that is restarting (a deploy)", () => {
|
|||||||
hlses[0]!.lost(500);
|
hlses[0]!.lost(500);
|
||||||
expect(engine.getSnapshot().error).toBe("generic");
|
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", () => {
|
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
|
// 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
|
// 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
|
||||||
|
// 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;
|
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]);
|
||||||
// The server's answer for a session it no longer has; its 404 means a segment not there yet.
|
// The server's answer for a session it no longer has; its 404 means a segment not there yet.
|
||||||
const SESSION_GONE = 410;
|
const SESSION_GONE = 410;
|
||||||
|
|
||||||
/** Why a session could not play, in the three buckets the player has a sentence for. */
|
/** Why a session could not play, in the buckets the player has a sentence for. `slow`: the link
|
||||||
type EngineError = "transcode" | "notFound" | "generic";
|
* 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. */
|
/** What the player renders from the engine. A new object whenever any of it changes. */
|
||||||
export interface EngineSnapshot {
|
export interface EngineSnapshot {
|
||||||
@@ -55,14 +61,27 @@ export interface HlsFailure {
|
|||||||
fatal: boolean;
|
fatal: boolean;
|
||||||
network: boolean;
|
network: boolean;
|
||||||
status: number | null;
|
status: number | null;
|
||||||
|
/** A request that got no answer in time (no response, so `status` is null). */
|
||||||
|
timeout: boolean;
|
||||||
type: string;
|
type: string;
|
||||||
details: 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). */
|
/** One hls.js instance, narrowed to what the engine uses (hlsPort.ts binds the real one). */
|
||||||
export interface HlsPort {
|
export interface HlsPort {
|
||||||
load(url: string, media: HTMLMediaElement): void;
|
load(url: string, media: HTMLMediaElement): void;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
|
/** Start loading again where it stopped, after a fatal network error (same session). */
|
||||||
|
resume(): void;
|
||||||
getAudioTrack(): number;
|
getAudioTrack(): number;
|
||||||
setAudioTrack(track: number): void;
|
setAudioTrack(track: number): void;
|
||||||
levelInfo(): HlsLevelInfo;
|
levelInfo(): HlsLevelInfo;
|
||||||
@@ -71,7 +90,7 @@ export interface HlsPort {
|
|||||||
export interface HlsHandlers {
|
export interface HlsHandlers {
|
||||||
onManifestParsed(): void;
|
onManifestParsed(): void;
|
||||||
onAudioTracksUpdated(): void;
|
onAudioTracksUpdated(): void;
|
||||||
onFragLoaded(frag: { type: string; bytes: number; durationS: number }): void;
|
onFragLoaded(frag: FragLoaded): void;
|
||||||
onError(e: HlsFailure): void;
|
onError(e: HlsFailure): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +125,7 @@ export interface EngineDeps {
|
|||||||
statusOf(e: unknown): number | undefined;
|
statusOf(e: unknown): number | undefined;
|
||||||
/** A request for a session went out: the player clears its stall spinner. */
|
/** A request for a session went out: the player clears its stall spinner. */
|
||||||
onLoadStart?(): void;
|
onLoadStart?(): void;
|
||||||
onFragLoaded?(frag: { type: string; bytes: number; durationS: number }): void;
|
onFragLoaded?(frag: FragLoaded): void;
|
||||||
onHlsError?(e: HlsFailure): void;
|
onHlsError?(e: HlsFailure): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +377,9 @@ export class HlsEngine {
|
|||||||
}
|
}
|
||||||
const media = this.deps.media();
|
const media = this.deps.media();
|
||||||
if (!media) return;
|
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;
|
const { intent, startAt, id } = req;
|
||||||
// The stream that kept playing is replaced from here: the new one loads like any other.
|
// The stream that kept playing is replaced from here: the new one loads like any other.
|
||||||
if (req.quiet) this.set({ ready: false });
|
if (req.quiet) this.set({ ready: false });
|
||||||
@@ -456,7 +477,10 @@ export class HlsEngine {
|
|||||||
/* out of range — ignore */
|
/* 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),
|
onError: (e) => this.onHlsError(req, media, e),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -489,6 +513,10 @@ export class HlsEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!e.fatal) 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 && SERVER_AWAY.has(e.status ?? 0) && this.serverAway(req)) return;
|
||||||
if (e.network && e.status === 404) {
|
if (e.network && e.status === 404) {
|
||||||
this.reclaim(req, media, false);
|
this.reclaim(req, media, false);
|
||||||
@@ -538,6 +566,23 @@ export class HlsEngine {
|
|||||||
return true;
|
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
|
/** 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.
|
* 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
|
* 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
|
// 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
|
// 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.
|
// 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 { 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", () => {
|
vi.mock("hls.js", () => {
|
||||||
class FakeHls {
|
class FakeHls {
|
||||||
static isSupported = () => true;
|
static isSupported = () => true;
|
||||||
static Events = {};
|
static Events = {
|
||||||
static ErrorTypes = {};
|
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>) {
|
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 };
|
return { default: FakeHls };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { createHlsPort } = await import("./hlsPort");
|
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", () => {
|
describe("createHlsPort", () => {
|
||||||
it("builds hls.js without the controllers that clear every text track's cues", () => {
|
it("builds hls.js without the controllers that clear every text track's cues", () => {
|
||||||
const noop = () => {};
|
const { hls } = build();
|
||||||
createHlsPort({
|
|
||||||
onManifestParsed: noop,
|
|
||||||
onAudioTracksUpdated: noop,
|
|
||||||
onFragLoaded: noop,
|
|
||||||
onError: noop,
|
|
||||||
});
|
|
||||||
const config = built[0] ?? {};
|
|
||||||
for (const key of [
|
for (const key of [
|
||||||
"subtitleTrackController",
|
"subtitleTrackController",
|
||||||
"subtitleStreamController",
|
"subtitleStreamController",
|
||||||
"timelineController",
|
"timelineController",
|
||||||
]) {
|
]) {
|
||||||
// Set, as undefined: hls.js spreads the config over its defaults, so an absent key keeps them.
|
// Set, as undefined: hls.js spreads the config over its defaults, so an absent key keeps them.
|
||||||
expect(key in config).toBe(true);
|
expect(key in hls.config).toBe(true);
|
||||||
expect(config[key]).toBeUndefined();
|
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
|
// 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
|
// 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.
|
// 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({
|
const hls = new Hls({
|
||||||
maxBufferLength: 30,
|
maxBufferLength: 30,
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
startPosition: 0,
|
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,
|
subtitleTrackController: undefined,
|
||||||
subtitleStreamController: undefined,
|
subtitleStreamController: undefined,
|
||||||
timelineController: 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.MANIFEST_PARSED, () => handlers.onManifestParsed());
|
||||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
|
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
|
// The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a
|
||||||
// measurement (see plexStats.ts).
|
// measurement (see plexStats.ts). When its download ran says how fast the link delivers.
|
||||||
hls.on(Hls.Events.FRAG_LOADED, (_e, data) =>
|
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
|
||||||
|
const l = data.frag.stats?.loading;
|
||||||
handlers.onFragLoaded({
|
handlers.onFragLoaded({
|
||||||
type: data.frag.type,
|
type: data.frag.type,
|
||||||
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
|
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
|
||||||
durationS: data.frag.duration,
|
durationS: data.frag.duration,
|
||||||
}),
|
loading: l && l.end > l.start ? { start: l.start, end: l.end } : undefined,
|
||||||
);
|
});
|
||||||
|
});
|
||||||
hls.on(Hls.Events.ERROR, (_e, data) =>
|
hls.on(Hls.Events.ERROR, (_e, data) =>
|
||||||
handlers.onError({
|
handlers.onError({
|
||||||
fatal: data.fatal,
|
fatal: data.fatal,
|
||||||
network: data.type === Hls.ErrorTypes.NETWORK_ERROR,
|
network: data.type === Hls.ErrorTypes.NETWORK_ERROR,
|
||||||
status: data.response?.code ?? null,
|
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,
|
type: data.type,
|
||||||
details: data.details,
|
details: data.details,
|
||||||
}),
|
}),
|
||||||
@@ -51,6 +69,10 @@ export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
|
|||||||
hls.attachMedia(media);
|
hls.attachMedia(media);
|
||||||
},
|
},
|
||||||
destroy: () => hls.destroy(),
|
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,
|
getAudioTrack: () => hls.audioTrack,
|
||||||
setAudioTrack(track) {
|
setAudioTrack(track) {
|
||||||
hls.audioTrack = 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 {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useEffectEvent,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
@@ -47,6 +48,49 @@ export function useWallClock(): Date {
|
|||||||
return now;
|
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.
|
// 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
|
// 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
|
// 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);
|
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", () => {
|
it("has nothing to say before the first fragment, and after a reset", () => {
|
||||||
const m = createFragMeter();
|
const m = createFragMeter();
|
||||||
expect(m.bitrate()).toBeUndefined(); // an honest dash — never the manifest's stand-in number
|
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"
|
type: string; // hls.js fragment type: "main" (video, and muxed audio) / "audio" / "subtitle"
|
||||||
bytes: number;
|
bytes: number;
|
||||||
durationS: number; // MEDIA seconds the fragment carries (not how long it took to download)
|
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 {
|
export interface FragMeter {
|
||||||
add(sample: FragSample): void;
|
add(sample: FragSample): void;
|
||||||
/** Bits per second of media, or undefined while nothing has been measured yet. */
|
/** Bits per second of media, or undefined while nothing has been measured yet. */
|
||||||
bitrate(): number | undefined;
|
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;
|
reset(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +228,22 @@ export function createFragMeter(window = 10): FragMeter {
|
|||||||
}
|
}
|
||||||
return total > 0 ? total : undefined;
|
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() {
|
reset() {
|
||||||
byType.clear();
|
byType.clear();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ import {
|
|||||||
usePlayerVolume,
|
usePlayerVolume,
|
||||||
usePlexProgress,
|
usePlexProgress,
|
||||||
usePlexTracks,
|
usePlexTracks,
|
||||||
|
useSlowLink,
|
||||||
useWallClock,
|
useWallClock,
|
||||||
|
slowLinkRates,
|
||||||
} from "../../lib/plexPlayerHooks";
|
} from "../../lib/plexPlayerHooks";
|
||||||
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
||||||
import {
|
import {
|
||||||
@@ -101,7 +103,7 @@ import { MenuCheck } from "../../components/ui/MenuCheck";
|
|||||||
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||||
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||||
import { Slider } from "../../components/ui/NativeControls";
|
import { Slider } from "../../components/ui/NativeControls";
|
||||||
import { formatUnit } from "../../lib/format";
|
import { formatMbps, formatUnit } from "../../lib/format";
|
||||||
import { trackLabel } from "../../lib/vocab";
|
import { trackLabel } from "../../lib/vocab";
|
||||||
import { msg } from "../../lib/notifications";
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
@@ -286,9 +288,16 @@ export default function PlexPlayer({
|
|||||||
? t("plex.player.errTranscode")
|
? t("plex.player.errTranscode")
|
||||||
: engineError === "notFound"
|
: engineError === "notFound"
|
||||||
? t("plex.player.errNotFound")
|
? t("plex.player.errNotFound")
|
||||||
: engineError
|
: engineError === "slow"
|
||||||
? t("plex.player.errGeneric")
|
? t("plex.player.errSlow")
|
||||||
: null;
|
: 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).
|
// 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
|
||||||
@@ -1326,7 +1335,17 @@ export default function PlexPlayer({
|
|||||||
{/* Announce the stall to assistive tech. Initial load shows visible text; a mid-playback
|
{/* 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
|
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. */}
|
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>
|
<span>
|
||||||
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
|
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user