During a deploy the gateway answers 502 (or nothing answers at all) until the app is back. The player took the first such answer for a broken stream: it said "Playback couldn't start" over a picture still playing from its buffer, stopped loading, and never asked again, so the episode froze where the buffer ended with the error still up. Now a 502/503/504 or no answer is the server being away: the buffer keeps playing undisturbed, the session is asked for again where the viewer is (2, 4, 8, then every 15 s), and the error is said only after three minutes away. A seek or another item replaces a pending retry. Measured on localdev: a 45 s API stop now resumes at the viewer's position, where it froze with the error before.
688 lines
24 KiB
TypeScript
688 lines
24 KiB
TypeScript
// The Plex player's session machine, without React, hls.js or a media pipeline: every race it exists
|
|
// to win, driven step by step. PlexPlayer's DOM tests hold the same behaviour through the component.
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import type { PlexPlaySession } from "./api";
|
|
import {
|
|
AWAY_WINDOW_MS,
|
|
HlsEngine,
|
|
MAX_SESSION_RECLAIMS,
|
|
RECLAIM_WINDOW_MS,
|
|
type EngineDeps,
|
|
type HlsHandlers,
|
|
type HlsPort,
|
|
type SessionParams,
|
|
} from "./hlsEngine";
|
|
import { HLS_SUB_LAG } from "./plexPlayerPrefs";
|
|
|
|
/** A <video> stand-in: the media calls the engine makes, and events the test fires. */
|
|
class FakeMedia extends EventTarget {
|
|
paused = true;
|
|
currentTime = 0;
|
|
src = "";
|
|
seekEnd = 0;
|
|
plays = 0;
|
|
get seekable() {
|
|
const end = this.seekEnd;
|
|
return { length: end > 0 ? 1 : 0, end: () => end };
|
|
}
|
|
play() {
|
|
this.plays++;
|
|
this.paused = false;
|
|
return Promise.resolve();
|
|
}
|
|
pause() {
|
|
this.paused = true;
|
|
}
|
|
load() {}
|
|
removeAttribute(name: string) {
|
|
if (name === "src") this.src = "";
|
|
}
|
|
fire(type: string) {
|
|
this.dispatchEvent(new Event(type));
|
|
}
|
|
}
|
|
|
|
class FakeHls implements HlsPort {
|
|
loaded: string | null = null;
|
|
destroyed = false;
|
|
audioTrack = 0;
|
|
constructor(readonly handlers: HlsHandlers) {}
|
|
load(url: string) {
|
|
this.loaded = url;
|
|
}
|
|
destroy() {
|
|
this.destroyed = true;
|
|
}
|
|
getAudioTrack() {
|
|
return this.audioTrack;
|
|
}
|
|
setAudioTrack(track: number) {
|
|
this.audioTrack = track;
|
|
}
|
|
levelInfo() {
|
|
return { index: 0, total: 1 };
|
|
}
|
|
lost404() {
|
|
this.lost(404);
|
|
}
|
|
/** hls.js gave up on a playlist or fragment with this answer (null: no answer at all). */
|
|
lost(status: number | null) {
|
|
this.handlers.onError({
|
|
fatal: true,
|
|
network: true,
|
|
status,
|
|
type: "networkError",
|
|
details: "levelLoadError",
|
|
});
|
|
}
|
|
}
|
|
|
|
interface Ask {
|
|
id: string;
|
|
startAt: number;
|
|
params: SessionParams;
|
|
resolve(s: PlexPlaySession): Promise<void>;
|
|
reject(e: unknown): Promise<void>;
|
|
}
|
|
|
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
|
|
function setup(over: Partial<EngineDeps> = {}) {
|
|
const media = new FakeMedia();
|
|
const asks: Ask[] = [];
|
|
const hlses: FakeHls[] = [];
|
|
const stops: [string, string][] = [];
|
|
// The engine's timers, run by hand: `fire` runs the next one still armed.
|
|
const timers: { fn: () => void; ms: number; armed: boolean }[] = [];
|
|
let clock = 1_000_000;
|
|
const state = { position: 0, duration: 3000, params: { multi: false, audio: null, aoff: 0 } };
|
|
const engine = new HlsEngine({
|
|
media: () => media as unknown as HTMLMediaElement,
|
|
requestSession: (id, startAt, params) =>
|
|
new Promise<PlexPlaySession>((res, rej) => {
|
|
asks.push({
|
|
id,
|
|
startAt,
|
|
params,
|
|
resolve: async (s) => {
|
|
res(s);
|
|
await flush();
|
|
},
|
|
reject: async (e) => {
|
|
rej(e);
|
|
await flush();
|
|
},
|
|
});
|
|
}),
|
|
stopSession: (id, sid) => void stops.push([id, sid]),
|
|
createHls: (h) => {
|
|
const hls = new FakeHls(h);
|
|
hlses.push(hls);
|
|
return hls;
|
|
},
|
|
params: () => state.params,
|
|
position: () => state.position,
|
|
duration: () => state.duration,
|
|
now: () => clock,
|
|
statusOf: (e) => (e as { status?: number }).status,
|
|
later: (fn, ms) => {
|
|
const t = { fn, ms, armed: true };
|
|
timers.push(t);
|
|
return () => void (t.armed = false);
|
|
},
|
|
...over,
|
|
});
|
|
return {
|
|
engine,
|
|
media,
|
|
asks,
|
|
hlses,
|
|
stops,
|
|
state,
|
|
timers,
|
|
advance: (ms: number) => void (clock += ms),
|
|
/** Let the next armed timer run (its delay passes on the clock too); false when none is. */
|
|
fire: () => {
|
|
const t = timers.find((x) => x.armed);
|
|
if (!t) return false;
|
|
t.armed = false;
|
|
clock += t.ms;
|
|
t.fn();
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
const hls = (id: string, start_s = 0, sid = `s-${id}-${start_s}`): PlexPlaySession => ({
|
|
mode: "hls",
|
|
url: `/hls/${id}/index.m3u8`,
|
|
start_s,
|
|
id: sid,
|
|
});
|
|
const direct = (id: string): PlexPlaySession => ({
|
|
mode: "direct",
|
|
url: `/media/${id}`,
|
|
start_s: 0,
|
|
});
|
|
|
|
describe("one request per item in flight", () => {
|
|
it("queues a restart asked for while the start is out, and sends only the LAST target", async () => {
|
|
const { engine, asks } = setup();
|
|
engine.start("A", 0);
|
|
for (let t = 100; t <= 2000; t += 100) engine.seek("A", t);
|
|
expect(asks.map((a) => a.startAt)).toEqual([0]);
|
|
await asks[0]!.resolve(hls("A"));
|
|
expect(asks.map((a) => a.startAt)).toEqual([0, 2000]);
|
|
});
|
|
|
|
it("drops the stale answer it queued behind — it never attaches", async () => {
|
|
const { engine, asks, hlses, media } = setup();
|
|
engine.start("A", 0);
|
|
engine.seek("A", 500);
|
|
await asks[0]!.resolve(hls("A", 0));
|
|
expect(hlses).toHaveLength(0);
|
|
await asks[1]!.resolve(hls("A", 500));
|
|
expect(hlses).toHaveLength(1);
|
|
expect(hlses[0]!.loaded).toBe("/hls/A/index.m3u8?t=500");
|
|
expect(media.src).toBe("");
|
|
});
|
|
|
|
it("still sends the queued target when the in-flight request fails, and shows no error for it", async () => {
|
|
const { engine, asks } = setup();
|
|
engine.start("A", 0);
|
|
engine.seek("A", 700);
|
|
await asks[0]!.reject({ status: 500 });
|
|
expect(asks.map((a) => a.startAt)).toEqual([0, 700]);
|
|
expect(engine.getSnapshot().error).toBeNull();
|
|
});
|
|
|
|
it("sends another item's request at once, and drops the first item's queue", async () => {
|
|
const { engine, asks } = setup();
|
|
engine.start("A", 0);
|
|
engine.seek("A", 900);
|
|
engine.leaveItem();
|
|
engine.start("B", 0);
|
|
expect(asks.map((a) => [a.id, a.startAt])).toEqual([
|
|
["A", 0],
|
|
["B", 0],
|
|
]);
|
|
await asks[0]!.resolve(hls("A"));
|
|
await asks[1]!.resolve(direct("B"));
|
|
expect(asks).toHaveLength(2); // A's 900 is gone with A, whichever answer settles first
|
|
});
|
|
|
|
it("never sends a queued target of an item the viewer left before the next one started", async () => {
|
|
// The next item's detail may still be loading: nothing replaced the queue yet.
|
|
const { engine, asks } = setup();
|
|
engine.start("A", 0);
|
|
engine.seek("A", 900);
|
|
engine.leaveItem();
|
|
await asks[0]!.resolve(hls("A"));
|
|
expect(asks).toHaveLength(1);
|
|
});
|
|
|
|
it("reads the track parameters when the queued request is SENT", async () => {
|
|
const { engine, asks, state } = setup();
|
|
engine.start("A", 100);
|
|
engine.start("A", 100); // an audio switch while the start is out
|
|
state.params = { multi: false, audio: 1, aoff: 0 } as never;
|
|
await asks[0]!.resolve(hls("A", 100));
|
|
expect(asks[1]!.params.audio).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("answers that land late", () => {
|
|
it("are dropped after the viewer moved to another item — and the server session is stopped", async () => {
|
|
const { engine, asks, hlses, stops } = setup();
|
|
engine.start("A", 0);
|
|
engine.leaveItem();
|
|
engine.start("B", 0);
|
|
await asks[1]!.resolve(direct("B"));
|
|
await asks[0]!.resolve(hls("A", 0, "sA"));
|
|
expect(hlses).toHaveLength(0);
|
|
expect(stops).toEqual([["A", "sA"]]);
|
|
});
|
|
|
|
it("are dropped after the player closed", async () => {
|
|
const { engine, asks, media } = setup();
|
|
engine.start("A", 0);
|
|
engine.destroy(media as unknown as HTMLMediaElement);
|
|
await asks[0]!.resolve(direct("A"));
|
|
expect(media.src).toBe("");
|
|
});
|
|
|
|
it("land again once a StrictMode remount revived the engine", async () => {
|
|
const { engine, asks, media } = setup();
|
|
engine.destroy(media as unknown as HTMLMediaElement);
|
|
engine.revive();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(direct("A"));
|
|
expect(media.src).toBe("/media/A");
|
|
});
|
|
|
|
it("a superseded direct item's loadedmetadata does not seek the next one", async () => {
|
|
const { engine, asks, media } = setup();
|
|
engine.start("A", 1200);
|
|
await asks[0]!.resolve(direct("A"));
|
|
engine.leaveItem();
|
|
engine.start("B", 0);
|
|
await asks[1]!.resolve(direct("B"));
|
|
media.fire("loadedmetadata");
|
|
expect(media.currentTime).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("the server session", () => {
|
|
it("is stopped when the viewer leaves the item, and when the player closes", async () => {
|
|
const { engine, asks, stops, media } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A", 0, "sA"));
|
|
engine.leaveItem();
|
|
expect(stops).toEqual([["A", "sA"]]);
|
|
engine.start("B", 0);
|
|
await asks[1]!.resolve(hls("B", 0, "sB"));
|
|
engine.destroy(media as unknown as HTMLMediaElement);
|
|
expect(stops).toEqual([
|
|
["A", "sA"],
|
|
["B", "sB"],
|
|
]);
|
|
});
|
|
|
|
it("is not stopped by a restart of the same item — the server replaces it itself", async () => {
|
|
const { engine, asks, stops } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A", 0, "s1"));
|
|
engine.seek("A", 2000);
|
|
await asks[1]!.resolve(hls("A", 2000, "s2"));
|
|
expect(stops).toEqual([]);
|
|
});
|
|
|
|
it("is stopped when a direct stream of the same item replaces it", async () => {
|
|
const { engine, asks, stops } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A", 0, "s1"));
|
|
engine.start("A", 0); // an A/V offset back at 0 plays the file directly
|
|
await asks[1]!.resolve(direct("A"));
|
|
expect(stops).toEqual([["A", "s1"]]);
|
|
});
|
|
|
|
it("is stopped when Safari plays it natively too (no hls.js)", async () => {
|
|
const { engine, asks, stops, media } = setup({ createHls: () => null });
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A", 0, "sA"));
|
|
expect(media.src).toBe("/hls/A/index.m3u8");
|
|
engine.leaveItem();
|
|
expect(stops).toEqual([["A", "sA"]]);
|
|
});
|
|
|
|
it("is stopped on an unload, but not when the page goes into the back/forward cache", async () => {
|
|
const { engine, asks, stops } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A", 0, "sA"));
|
|
engine.pageHidden(true);
|
|
expect(stops).toEqual([]);
|
|
engine.pageHidden(false);
|
|
expect(stops).toEqual([["A", "sA"]]);
|
|
});
|
|
});
|
|
|
|
describe("a failed session request", () => {
|
|
it.each([
|
|
[404, "notFound"],
|
|
[501, "transcode"],
|
|
[500, "generic"],
|
|
] as const)("%s says %s", async (status, error) => {
|
|
const { engine, asks } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.reject({ status });
|
|
expect(engine.getSnapshot().error).toBe(error);
|
|
});
|
|
|
|
it("keeps the old stream's clock after a failed seek-restart, not after a failed move", async () => {
|
|
const { engine, asks } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(direct("A"));
|
|
engine.seek("A", 2000);
|
|
expect(engine.clockLive).toBe(false);
|
|
await asks[1]!.reject({ status: 500 });
|
|
expect(engine.clockLive).toBe(true);
|
|
engine.leaveItem();
|
|
engine.start("B", 0);
|
|
await asks[2]!.reject({ status: 404 });
|
|
expect(engine.clockLive).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("a session the server no longer has (404)", () => {
|
|
it("is reclaimed where the viewer is", async () => {
|
|
const { engine, asks, hlses, state } = setup();
|
|
engine.start("A", 600);
|
|
await asks[0]!.resolve(hls("A", 600));
|
|
hlses[0]!.handlers.onManifestParsed();
|
|
state.position = 1299;
|
|
hlses[0]!.lost404();
|
|
expect(asks[1]!.startAt).toBe(1299);
|
|
});
|
|
|
|
it(`gives up after ${MAX_SESSION_RECLAIMS} reclaims inside the window, and says so`, async () => {
|
|
const { engine, asks, hlses } = setup();
|
|
engine.start("A", 600);
|
|
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
|
|
await asks[i]!.resolve(hls("A", 600));
|
|
hlses[i]!.lost404();
|
|
}
|
|
expect(asks).toHaveLength(MAX_SESSION_RECLAIMS + 1);
|
|
expect(engine.getSnapshot().error).toBe("generic");
|
|
});
|
|
|
|
it("rolls the window: losses closer than it keep counting, however slow", async () => {
|
|
const { engine, asks, hlses, advance } = setup();
|
|
engine.start("A", 600);
|
|
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
|
|
await asks[i]!.resolve(hls("A", 600));
|
|
hlses[i]!.lost404();
|
|
advance(RECLAIM_WINDOW_MS * 0.6);
|
|
}
|
|
expect(engine.getSnapshot().error).toBe("generic");
|
|
});
|
|
|
|
it("starts over for a loss long after the last one, and for a new item", async () => {
|
|
const { engine, asks, hlses, advance } = setup();
|
|
engine.start("A", 600);
|
|
for (let i = 0; i < 4; i++) {
|
|
await asks[i]!.resolve(hls("A", 600));
|
|
hlses[i]!.lost404();
|
|
advance(RECLAIM_WINDOW_MS + 1);
|
|
}
|
|
expect(engine.getSnapshot().error).toBeNull();
|
|
const fresh = setup();
|
|
fresh.engine.start("A", 0);
|
|
for (let i = 0; i < MAX_SESSION_RECLAIMS; i++) {
|
|
await fresh.asks[i]!.resolve(hls("A"));
|
|
fresh.hlses[i]!.lost404();
|
|
}
|
|
fresh.engine.newItem();
|
|
await fresh.asks[MAX_SESSION_RECLAIMS]!.resolve(hls("A"));
|
|
fresh.hlses[MAX_SESSION_RECLAIMS]!.lost404();
|
|
expect(fresh.engine.getSnapshot().error).toBeNull();
|
|
});
|
|
|
|
it("keeps the start's own intent to play when it was lost before it ever played", async () => {
|
|
const { engine, asks, hlses, media } = setup();
|
|
engine.start("A", 600);
|
|
await asks[0]!.resolve(hls("A", 600));
|
|
hlses[0]!.lost404(); // media is still paused: not the viewer's pause
|
|
await asks[1]!.resolve(hls("A", 600));
|
|
hlses[1]!.handlers.onManifestParsed();
|
|
expect(media.plays).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("a server that is restarting (a deploy)", () => {
|
|
// The gateway answers 502 while the app restarts, and nothing at all may answer (status 0): the
|
|
// stream is not broken, the server is away. The buffer keeps playing meanwhile.
|
|
it.each([502, 503, 504, 0, null])(
|
|
"is waited out on %s: no error, and the stream comes back where the viewer is",
|
|
async (status) => {
|
|
const { engine, asks, hlses, state, fire, media } = setup();
|
|
engine.start("A", 600);
|
|
await asks[0]!.resolve(hls("A", 600));
|
|
hlses[0]!.handlers.onManifestParsed();
|
|
state.position = 1299;
|
|
hlses[0]!.lost(status);
|
|
expect(engine.getSnapshot().error).toBeNull();
|
|
expect(asks).toHaveLength(1); // not at once: the server is still away
|
|
expect(fire()).toBe(true);
|
|
expect(asks[1]!.startAt).toBe(1299);
|
|
// The buffered picture is still playing while the server is asked: no "Loading…" over it.
|
|
expect(engine.getSnapshot().ready).toBe(true);
|
|
await asks[1]!.reject({ status: 502 }); // still away
|
|
expect(engine.getSnapshot().error).toBeNull();
|
|
state.position = 1310;
|
|
expect(fire()).toBe(true);
|
|
await asks[2]!.resolve(hls("A", 1310));
|
|
expect(hlses[1]!.loaded).toContain("?t=1310");
|
|
expect(engine.getSnapshot().ready).toBe(false); // the new stream is loading now
|
|
hlses[1]!.handlers.onManifestParsed();
|
|
expect(engine.getSnapshot()).toMatchObject({ ready: true, error: null });
|
|
expect(media.plays).toBeGreaterThan(0);
|
|
},
|
|
);
|
|
|
|
it("is waited out when the very first request finds it away", async () => {
|
|
const { engine, asks, fire } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.reject({ status: 0 });
|
|
expect(engine.getSnapshot()).toMatchObject({ ready: false, error: null });
|
|
expect(fire()).toBe(true);
|
|
await asks[1]!.resolve(hls("A"));
|
|
expect(engine.getSnapshot().error).toBeNull();
|
|
});
|
|
|
|
it("asks less and less often, and says so once it has been away too long", async () => {
|
|
const { engine, asks, hlses, fire, timers } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A"));
|
|
hlses[0]!.handlers.onManifestParsed();
|
|
hlses[0]!.lost(502);
|
|
let i = 1;
|
|
while (fire()) await asks[i++]!.reject({ status: 502 });
|
|
const delays = timers.map((t) => t.ms);
|
|
expect(delays[1]!).toBeGreaterThan(delays[0]!);
|
|
expect(delays.reduce((a, b) => a + b, 0)).toBeGreaterThan(AWAY_WINDOW_MS);
|
|
expect(engine.getSnapshot().error).toBe("generic");
|
|
});
|
|
|
|
it("starts the wait over after the server came back", async () => {
|
|
const { engine, asks, hlses, fire, advance } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A"));
|
|
hlses[0]!.handlers.onManifestParsed();
|
|
hlses[0]!.lost(502);
|
|
fire();
|
|
await asks[1]!.resolve(hls("A"));
|
|
hlses[1]!.handlers.onManifestParsed();
|
|
advance(AWAY_WINDOW_MS + 1);
|
|
hlses[1]!.lost(502); // a second deploy, long after the first
|
|
expect(engine.getSnapshot().error).toBeNull();
|
|
});
|
|
|
|
it("drops a pending retry once the viewer seeks or leaves the item", async () => {
|
|
const { engine, asks, hlses, fire } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A"));
|
|
hlses[0]!.handlers.onManifestParsed();
|
|
hlses[0]!.lost(502);
|
|
engine.seek("A", 2000); // the viewer's own restart goes out instead
|
|
expect(asks).toHaveLength(2);
|
|
fire();
|
|
expect(asks).toHaveLength(2);
|
|
await asks[1]!.reject({ status: 502 });
|
|
engine.leaveItem();
|
|
fire();
|
|
expect(asks).toHaveLength(2);
|
|
});
|
|
|
|
it("still says a real failure at once", async () => {
|
|
const { engine, asks, hlses } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A"));
|
|
hlses[0]!.handlers.onManifestParsed();
|
|
hlses[0]!.lost(500);
|
|
expect(engine.getSnapshot().error).toBe("generic");
|
|
});
|
|
});
|
|
|
|
describe("play intent while a session is asked for", () => {
|
|
it("goes into the pending session, and never plays the outgoing media", async () => {
|
|
const { engine, asks, media, hlses } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(direct("A"));
|
|
media.fire("loadedmetadata");
|
|
expect(media.paused).toBe(false);
|
|
engine.seek("A", 2000, false); // a restart while paused-by-request
|
|
expect(engine.requestPlay(false)).toBe(true);
|
|
expect(media.paused).toBe(true);
|
|
const plays = media.plays;
|
|
expect(engine.requestPlay(true)).toBe(true);
|
|
expect(media.plays).toBe(plays); // the outgoing stream stays silent
|
|
expect(engine.shownPlaying()).toBe(true);
|
|
await asks[1]!.resolve(hls("A", 2000));
|
|
hlses[0]!.handlers.onManifestParsed();
|
|
expect(media.plays).toBe(plays + 1);
|
|
});
|
|
});
|
|
|
|
describe("seeking", () => {
|
|
it("a restart asked for before the new session has played keeps that session's intent to play", async () => {
|
|
// Attaching a new session empties the media: it reads paused until hls.js parses and plays. A
|
|
// seek in that window read the paused element as "the viewer paused", and the next session
|
|
// attached paused at 0:00 (measured: a burst of ← ending in a frozen frame).
|
|
const { engine, asks, hlses, media } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A", 0));
|
|
expect(media.paused).toBe(true); // attached, not played yet
|
|
engine.seek("A", 2000);
|
|
await asks[1]!.resolve(hls("A", 2000));
|
|
hlses[1]!.handlers.onManifestParsed();
|
|
expect(media.paused).toBe(false);
|
|
});
|
|
|
|
it("is native inside the attached session's loaded region", async () => {
|
|
const { engine, asks, media } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A", 600));
|
|
media.seekEnd = 100;
|
|
engine.seek("A", 650);
|
|
expect(asks).toHaveLength(1);
|
|
expect(media.currentTime).toBeCloseTo(650 - (600 - HLS_SUB_LAG));
|
|
});
|
|
|
|
it("restarts past it, and while the clock is not the current session's", async () => {
|
|
const { engine, asks, media } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A", 600));
|
|
media.seekEnd = 100;
|
|
engine.seek("A", 900);
|
|
expect(asks.map((a) => a.startAt)).toEqual([0, 900]);
|
|
engine.seek("A", 650); // inside the OLD buffer, but a restart is pending
|
|
await asks[1]!.resolve(hls("A", 900));
|
|
expect(asks.map((a) => a.startAt)).toEqual([0, 900, 650]);
|
|
});
|
|
|
|
it("clamps the target to the item", () => {
|
|
const { engine } = setup();
|
|
expect(engine.clamp(-5)).toBe(0);
|
|
expect(engine.clamp(99_999)).toBe(2999);
|
|
});
|
|
});
|
|
|
|
describe("the session's clock", () => {
|
|
it("folds hls.js's lag into a resumed HLS session's start, not a direct one's", async () => {
|
|
const { engine, asks } = setup();
|
|
engine.start("A", 600);
|
|
await asks[0]!.resolve(hls("A", 600));
|
|
expect(engine.getSnapshot().sessionK).toBeCloseTo(600 - HLS_SUB_LAG);
|
|
engine.start("A", 0);
|
|
await asks[1]!.resolve(direct("A"));
|
|
expect(engine.getSnapshot().sessionK).toBe(0);
|
|
});
|
|
|
|
it("is not trusted between asking for a session and its answer", async () => {
|
|
const { engine, asks, media } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(direct("A"));
|
|
media.currentTime = 50;
|
|
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBe(50);
|
|
engine.releaseClock();
|
|
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBeNull();
|
|
});
|
|
|
|
it("answers 'where the viewer is' with the asked start before playback has ticked", () => {
|
|
const { engine, state } = setup();
|
|
engine.start("A", 1200);
|
|
expect(engine.playhead()).toBe(1200);
|
|
state.position = 1250;
|
|
expect(engine.playhead()).toBe(1250);
|
|
});
|
|
});
|
|
|
|
describe("audio on a multi-rendition session", () => {
|
|
it("restores the chosen track once hls.js lists the renditions, and switches client-side", async () => {
|
|
const { engine, asks, hlses, state } = setup();
|
|
state.params = { multi: true, audio: 1, aoff: 0 } as never;
|
|
expect(engine.setAudioTrack(1)).toBe(false); // nothing attached yet: the caller restarts
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A"));
|
|
hlses[0]!.handlers.onAudioTracksUpdated();
|
|
expect(hlses[0]!.audioTrack).toBe(1);
|
|
expect(engine.setAudioTrack(0)).toBe(true);
|
|
expect(hlses[0]!.audioTrack).toBe(0);
|
|
expect(asks).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it("tells React only when the snapshot really changed", async () => {
|
|
const { engine, asks } = setup();
|
|
const seen = vi.fn();
|
|
engine.subscribe(seen);
|
|
engine.start("A", 0);
|
|
const after = seen.mock.calls.length;
|
|
engine.start("A", 0); // same ready/error/transcode — nothing new to render
|
|
expect(seen.mock.calls.length).toBe(after);
|
|
await asks[0]!.resolve(direct("A"));
|
|
});
|
|
|
|
describe("an item the viewer comes back to while its request is still out", () => {
|
|
it("waits for that request instead of sending a second one beside it (A → B → A)", async () => {
|
|
// The in-flight request was tracked in one slot: B's request took it, and the way back to A
|
|
// sent A2 next to the A1 still out — two spawns for one item, and the loser's answer could
|
|
// name the winner's session for the stop.
|
|
const { engine, asks } = setup();
|
|
engine.start("A", 0);
|
|
engine.leaveItem();
|
|
engine.start("B", 0);
|
|
engine.leaveItem();
|
|
engine.start("A", 0);
|
|
expect(asks.map((a) => a.id)).toEqual(["A", "B"]);
|
|
await asks[0]!.resolve(hls("A", 0, "s1"));
|
|
expect(asks.map((a) => a.id)).toEqual(["A", "B", "A"]);
|
|
});
|
|
});
|
|
|
|
describe("a session that was replaced before its manifest parsed", () => {
|
|
it("neither marks the player ready nor plays the outgoing media", async () => {
|
|
const { engine, asks, hlses, media } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A"));
|
|
engine.seek("A", 900); // nothing seekable yet: a restart
|
|
const plays = media.plays;
|
|
hlses[0]!.handlers.onManifestParsed(); // the replaced instance parses late
|
|
expect(engine.getSnapshot().ready).toBe(false);
|
|
expect(media.plays).toBe(plays);
|
|
});
|
|
});
|
|
|
|
describe("a pause given after the answer, before the media can play", () => {
|
|
it("stands: neither the manifest nor the first canplay plays it again", async () => {
|
|
const { engine, asks, hlses, media } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A"));
|
|
engine.requestPlay(false);
|
|
hlses[0]!.handlers.onManifestParsed();
|
|
expect(media.paused).toBe(true);
|
|
media.fire("canplay");
|
|
expect(media.paused).toBe(true);
|
|
});
|
|
|
|
it("stands after the manifest played it too", async () => {
|
|
const { engine, asks, hlses, media } = setup();
|
|
engine.start("A", 0);
|
|
await asks[0]!.resolve(hls("A"));
|
|
hlses[0]!.handlers.onManifestParsed();
|
|
engine.requestPlay(false);
|
|
media.fire("canplay");
|
|
expect(media.paused).toBe(true);
|
|
});
|
|
});
|