fix(plex): the player drops a session answer that lands late, and Next neither loses nor lends a position
Deep-review round 3 of bug hunt 2026-09. - loadSession checks, after its await, that it is still the newest call and the player is still mounted. A late answer used to replace what was playing (Next twice quickly: the slower transcode landed last and played the middle item under the last one's title, recording its progress there) or attach a stream to the detached <video> after Back, never destroyed. - The <video>'s clock is trusted only once the new session has answered. go()'s pause() fires a timeupdate AFTER the reset to 0 (measured in Chrome), so the previous item's position was sent as the next item's subtitle position and saved as its progress (final, pushed to Plex). - Next settles the item being left (final checkpoint + reopen-cache patch) before switching: the progress cleanup ran with the position already reset and skipped, so every Next dropped the last stretch watched. - A seek-restart puts its target into the position state too; the ref alone was re-synced back to the pre-seek spot by the restart's own render. - The unmount teardown captures the <video> at mount: React detaches refs before an unmount's cleanups, so the stop/detach never ran. - The tracks panel exposes the selected audio/subtitle track via aria-pressed. Verified in real Chrome on localdev (Westworld S1E6 → Next): the item left was settled at 783 s, the next asked for subtitles at its own start (0), and no progress was written for it.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player's session lifecycle, rendered for real (deep-review round 3 of bug hunt 2026-09).
|
||||
//
|
||||
// jsdom has no media pipeline, so the <video> is a small stand-in that behaves like Chrome where it
|
||||
// matters here: `pause()` fires its `timeupdate` in a LATER task (measured in Chrome — which is how the
|
||||
// previous item's position used to arrive after the player had already reset it for the next one).
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number };
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
const later = (el: HTMLMediaElement, ...types: string[]) =>
|
||||
setTimeout(() => types.forEach((ty) => el.dispatchEvent(new Event(ty))), 0);
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
|
||||
configurable: true,
|
||||
get(this: Media) {
|
||||
return this._t ?? 0;
|
||||
},
|
||||
set(this: Media, v: number) {
|
||||
this._t = v;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
|
||||
configurable: true,
|
||||
value(this: HTMLMediaElement) {
|
||||
later(this, "timeupdate", "pause"); // as Chrome does: after the call has returned
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "load", {
|
||||
configurable: true,
|
||||
value(this: Media) {
|
||||
this._t = 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// A signed-in account whose saved subtitle language is English (prefs are per account).
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const item = (id: string, position = 0): PlexItemDetail =>
|
||||
({
|
||||
id,
|
||||
title: `Title ${id}`,
|
||||
type: "episode",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: position,
|
||||
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
||||
subtitle_streams: [{ ord: 0, label: "English", language: "en", text: true }],
|
||||
markers: [],
|
||||
}) as unknown as PlexItemDetail;
|
||||
|
||||
const direct = (id: string) =>
|
||||
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
|
||||
ReturnType<typeof api.plexSession>
|
||||
>;
|
||||
|
||||
/** A session answer the test releases when it wants to. */
|
||||
function deferred() {
|
||||
let resolve!: (v: Awaited<ReturnType<typeof api.plexSession>>) => void;
|
||||
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function mount(opts: {
|
||||
details: Record<string, PlexItemDetail>;
|
||||
sessions: Record<string, () => Promise<Awaited<ReturnType<typeof api.plexSession>>>>;
|
||||
queue?: string[];
|
||||
qc?: QueryClient;
|
||||
}) {
|
||||
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
||||
vi.spyOn(api, "plexSession").mockImplementation((id: string) => opts.sessions[id]!());
|
||||
const progress = vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
const status = vi
|
||||
.spyOn(api, "plexSubtitleStatus")
|
||||
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
||||
const qc = opts.qc ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const first = opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
||||
const view = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, progress, status, qc };
|
||||
}
|
||||
|
||||
const video = () => document.querySelector("video") as Media;
|
||||
|
||||
async function playingAt(src: string, t: number) {
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
|
||||
const v = video();
|
||||
v._t = t;
|
||||
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
||||
}
|
||||
|
||||
const tick = () => act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
describe("moving to the next item", () => {
|
||||
it("settles the item being left, and gives the next one none of its position", async () => {
|
||||
const { progress, status, unmount } = mount({
|
||||
details: { A: item("A"), B: item("B", 300) },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(status.mock.calls.some(([id]) => id === "B")).toBe(true));
|
||||
await tick(); // the timeupdate that pause() queued has landed by now
|
||||
// B's session has not answered, so where B is is where it was asked to start — not A's 25:00.
|
||||
expect(status.mock.calls.filter(([id]) => id === "B").map(([, , at]) => at)).not.toContain(
|
||||
1500,
|
||||
);
|
||||
expect(status.mock.calls.find(([id]) => id === "B")?.[2]).toBe(300);
|
||||
// A got its final checkpoint at the position it was left at…
|
||||
expect(progress.mock.calls).toContainEqual(["A", 1500, 3000, true]);
|
||||
unmount();
|
||||
// …and leaving B while it was still loading saved nothing of A's position as B's.
|
||||
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a seek that restarts the session is saved at its target while the new session loads", async () => {
|
||||
// seekTo wrote the target into the position ref only — and the restart re-renders at once, which
|
||||
// re-syncs that ref from the state: a page-hide during the restart saved the pre-seek spot.
|
||||
let calls = 0;
|
||||
mount({
|
||||
details: { A: item("A") },
|
||||
sessions: {
|
||||
A: () => (calls++ === 0 ? Promise.resolve(direct("A")) : new Promise(() => {})),
|
||||
},
|
||||
});
|
||||
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
|
||||
await waitFor(() => expect(api.plexSession).toHaveBeenCalledTimes(2)); // a restart, not a native seek
|
||||
await tick();
|
||||
act(() => void window.dispatchEvent(new Event("pagehide")));
|
||||
expect(beacon).toHaveBeenLastCalledWith("A", 1510, 3000, true);
|
||||
});
|
||||
|
||||
describe("a session answer that lands late", () => {
|
||||
it("is dropped when the viewer has moved on to another item meanwhile", async () => {
|
||||
const slowB = deferred();
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B"), C: item("C") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => slowB.promise,
|
||||
C: () => Promise.resolve(direct("C")),
|
||||
},
|
||||
queue: ["A", "B", "C"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await screen.findByText("Title B");
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/C"));
|
||||
await act(async () => slowB.resolve(direct("B")));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).toBe("/media/C");
|
||||
});
|
||||
|
||||
it("is dropped when the player has been closed meanwhile", async () => {
|
||||
const slowA = deferred();
|
||||
const { unmount } = mount({ details: { A: item("A") }, sessions: { A: () => slowA.promise } });
|
||||
await waitFor(() => expect(api.plexSession).toHaveBeenCalled());
|
||||
const v = video();
|
||||
unmount();
|
||||
await act(async () => slowA.resolve(direct("A")));
|
||||
await tick();
|
||||
expect(v.getAttribute("src")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closing the player detaches the <video> it was playing", async () => {
|
||||
const { unmount } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
const v = video();
|
||||
unmount();
|
||||
expect(v.getAttribute("src")).toBeNull();
|
||||
});
|
||||
|
||||
describe("the subtitle poll and notice", () => {
|
||||
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
|
||||
const { status } = mount({
|
||||
details: { A: item("A", 1198) },
|
||||
sessions: { A: () => new Promise(() => {}) },
|
||||
});
|
||||
await waitFor(() => expect(status).toHaveBeenCalled());
|
||||
expect(status.mock.calls[0]).toEqual(["A", 0, 1198]);
|
||||
});
|
||||
|
||||
it("says a failing poll even while an earlier window is still on screen", async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.plexSubtitle("A", 0), { state: "partial", code: null, gen: 1 });
|
||||
const view = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||
qc,
|
||||
});
|
||||
view.status.mockRejectedValue(new Error("offline"));
|
||||
await waitFor(() => expect(view.status).toHaveBeenCalled());
|
||||
await screen.findByText("plex.player.subUnavailable.failed");
|
||||
expect(document.querySelector("track")).not.toBeNull(); // the window stays mounted meanwhile
|
||||
});
|
||||
|
||||
it("marks which track is selected for assistive tech, not only by colour", async () => {
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
||||
await screen.findByText("Title A");
|
||||
fireEvent.click(screen.getByLabelText("plex.player.tracks"));
|
||||
const off = await screen.findByText("plex.player.subOff");
|
||||
expect(off.closest("button")?.getAttribute("aria-pressed")).toBe("false");
|
||||
const pressed = document.querySelectorAll('[aria-pressed="true"]');
|
||||
expect([...pressed].map((b) => b.textContent)).toEqual(["English"]); // the saved subtitle
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a toast raised while it is open inside itself, not under its scrim", async () => {
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
||||
await screen.findByText("Title A");
|
||||
act(() => notify({ level: "error", message: "Couldn't mark it watched." }));
|
||||
const toast = await screen.findByText("Couldn't mark it watched.");
|
||||
let host: Element | null = video();
|
||||
while (host && !host.contains(toast)) host = host.parentElement;
|
||||
expect(host).not.toBe(document.body); // the player's own host, beside its <video>
|
||||
});
|
||||
Reference in New Issue
Block a user