refactor(player): the Plex session machine is a tested engine, one request per item in flight
PlexPlayer's session logic (generation, pending play intent, clock ownership, session offset, reclaim
budget, listener teardown) moves into lib/hlsEngine.ts, a plain class with hls.js behind lib/hlsPort.ts
and its own unit tests.
- A seek, track change or reclaim asked for while a session request is out replaces the queued
target; it is sent once the in-flight answer settles. A burst of 20 presses past the buffer was 20
requests and 20 ffmpeg spawns; it is now 2-3, and plays again ~2 s sooner.
- The player stops its server session when it leaves the item, closes or the page unloads
(POST /api/plex/stream/{rk}/session/{sid}/stop, identity-aware). A left transcode used to run to
the end of the film first.
- A restart asked for after a new session attached but before it played no longer reads the emptied
media as the viewer's pause.
This commit is contained in:
@@ -502,11 +502,18 @@ describe("restarting the session before playback has ticked", () => {
|
||||
{ ord: 1, label: "Magyar", language: "hu", default: false },
|
||||
],
|
||||
} as unknown as PlexItemDetail;
|
||||
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
|
||||
const answers = [deferred(), deferred(), deferred()];
|
||||
let calls = 0;
|
||||
mount({ details: { A: twoAudio }, sessions: { A: () => answers[calls++]!.promise } });
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
|
||||
fireEvent.keyDown(window, { key: "a" });
|
||||
// One request per item in flight: the switch waits for the start's answer…
|
||||
await act(async () => answers[0]!.resolve(direct("A")));
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
await act(async () => answers[1]!.resolve(direct("A")));
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
|
||||
// …and each restart asked where the resume put the viewer, not at the unticked 0:00.
|
||||
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
|
||||
});
|
||||
});
|
||||
@@ -529,12 +536,41 @@ it("a seek made while a restart is pending wins over it", async () => {
|
||||
await playingAt("/media/A", 95);
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
|
||||
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105]));
|
||||
await tick();
|
||||
expect(startsAsked("A")).toEqual([0, 105]); // 95 waits for the 105 answer: one request in flight
|
||||
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
|
||||
});
|
||||
|
||||
it("a burst of seeks past the buffer asks the server once more, not once per press", async () => {
|
||||
// Twenty presses of ← at key-repeat pace past the buffer used to be twenty session requests —
|
||||
// twenty ffmpeg spawns on the server — and five seconds before the picture moved again (measured
|
||||
// on localdev, 2026-10-06).
|
||||
let calls = 0;
|
||||
const restart = deferred();
|
||||
mount({
|
||||
details: { A: item("A", 1200) },
|
||||
sessions: {
|
||||
A: () => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.resolve(direct("A"));
|
||||
return calls === 2 ? restart.promise : new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
await playingAt("/media/A", 1200);
|
||||
for (let i = 0; i < 20; i++) fireEvent.keyDown(document.body, { key: "ArrowLeft" });
|
||||
await tick();
|
||||
expect(startsAsked("A")).toEqual([1200, 1190]);
|
||||
await act(async () => restart.resolve(direct("A")));
|
||||
// The queued target is the LAST press's, asked once.
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([1200, 1190, 1000]));
|
||||
});
|
||||
|
||||
it("a direct-play item left before its metadata does not seek the next one", async () => {
|
||||
mount({
|
||||
details: { A: item("A", 1200), B: item("B") },
|
||||
|
||||
Reference in New Issue
Block a user