They were hand-built divs: no menu roles, no arrow keys, and Escape left the focus on <body>. Both run on the popover engine now (media-tone rows), and the tuning panel is a labelled dialog that takes the focus and hands it back to ⋯.
647 lines
28 KiB
TypeScript
647 lines
28 KiB
TypeScript
// @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, HttpError, 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.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step
|
|
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('[role="menuitemradio"]')?.getAttribute("aria-checked")).toBe("false");
|
|
const checked = document.querySelectorAll('[role="menuitemradio"][aria-checked="true"]');
|
|
expect([...checked].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>
|
|
});
|
|
|
|
// --- deep-review round 4 ------------------------------------------------------------------------
|
|
|
|
/** A session answer that FAILS when the test says so. */
|
|
function deferredFailure() {
|
|
let reject!: (e: unknown) => void;
|
|
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((_r, j) => (reject = j));
|
|
return { promise, reject };
|
|
}
|
|
|
|
const startsAsked = (id: string) =>
|
|
vi
|
|
.mocked(api.plexSession)
|
|
.mock.calls.filter(([i]) => i === id)
|
|
.map(([, at]) => at);
|
|
|
|
describe("leaving an item that was played through", () => {
|
|
const prefs = (p: object) =>
|
|
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
|
|
|
|
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
|
|
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
|
|
// exist, and hid the fact that the pause checkpoint goes out before the finish latch is up
|
|
// (deep-review round 5).
|
|
const endMedia = () => {
|
|
const el = video();
|
|
Object.defineProperty(el, "ended", { configurable: true, get: () => true });
|
|
act(() => void el.dispatchEvent(new Event("pause")));
|
|
act(() => void el.dispatchEvent(new Event("ended")));
|
|
};
|
|
|
|
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
|
|
prefs({ upNext: false });
|
|
const { qc, progress } = mount({
|
|
details: { A: item("A"), B: item("B") },
|
|
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
|
queue: ["A", "B"],
|
|
});
|
|
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
|
await playingAt("/media/A", 2995);
|
|
endMedia();
|
|
await screen.findByText("Title B");
|
|
await tick();
|
|
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
|
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
|
|
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
|
fireEvent.click(screen.getByLabelText("plex.player.prev"));
|
|
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
|
|
expect(startsAsked("A")[1]).toBe(0);
|
|
});
|
|
|
|
it("sends no checkpoint at the credits when the auto-skip leaves it", async () => {
|
|
// A final checkpoint at the credits raced the watched mark: landing second, it left the row
|
|
// watched WITH a resume point, and pushed that point to Plex right after the scrobble.
|
|
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
|
const withCredits = {
|
|
...item("A"),
|
|
markers: [{ type: "credits", start_s: 2900, end_s: 2995 }],
|
|
} as unknown as PlexItemDetail;
|
|
const { qc, progress } = mount({
|
|
details: { A: withCredits, B: item("B") },
|
|
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
|
queue: ["A", "B"],
|
|
});
|
|
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
|
// The 'pause' event (and its checkpoint) can land before the player has re-rendered for the
|
|
// next item — here it lands at once.
|
|
video().pause = function (this: HTMLMediaElement) {
|
|
this.dispatchEvent(new Event("pause"));
|
|
};
|
|
await playingAt("/media/A", 2910);
|
|
await screen.findByText("Title B");
|
|
await tick(); // the pause() that go() queued has fired its events by now
|
|
expect(setState).toHaveBeenCalledWith("A", "watched");
|
|
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
|
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
|
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
|
|
});
|
|
it("takes the finish back when the server refuses the watched mark", async () => {
|
|
// The mark was fired and forgotten (`.catch(() => {})`) while the cache was patched to
|
|
// watched / position 0 regardless, and the once-per-id start guard meant the corrected detail
|
|
// arriving later was never used: the episode reopened at 0:00 with a Watched badge the server
|
|
// disagreed with (round 5).
|
|
prefs({ upNext: false });
|
|
const { qc, progress } = mount({
|
|
details: { A: item("A"), B: item("B") },
|
|
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
|
queue: ["A", "B"],
|
|
});
|
|
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
|
await playingAt("/media/A", 2995);
|
|
endMedia();
|
|
await screen.findByText("Title B");
|
|
await tick();
|
|
// The cached detail is not left claiming watched: only the server is honest now.
|
|
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
|
expect(cached?.status).not.toBe("watched");
|
|
// And the rollback does NOT write the position back. 2995 of 3000 is inside the backend's
|
|
// finish margin, so that checkpoint would have set watched / position 0 and scrobbled to Plex
|
|
// — performing, through a second route, the very write that was just refused, while the error
|
|
// toast said it had failed (round 6). There is no resume point at the end of the media to
|
|
// restore; the cache drop is the whole of the rollback here.
|
|
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
|
});
|
|
|
|
it("performs the refused mark through no OTHER route while it stays on the finished item", async () => {
|
|
// Round 6 guarded the explicit rollback write, but the same catch lowered the play-through
|
|
// latch — and that latch is the only gate on the 10s interval, the page-hide beacon and
|
|
// `settle`. All three post `absRef`, which on this path IS the end of the media, and the
|
|
// backend reads a position within `_FINISH_MARGIN_S` of the duration as a finish: within ten
|
|
// seconds of the toast saying the mark had failed, the item was watched, its position cleared
|
|
// and a scrobble sent to Plex (deep-review round 7). `upNext` is ON here — the default — so the
|
|
// player STAYS on the finished item and those routes are live; the round-6 test above turns it
|
|
// off, which tears the effect down before any of them can fire.
|
|
prefs({ upNext: true });
|
|
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
|
|
const { qc, progress, unmount } = mount({
|
|
details: { A: item("A"), B: item("B") },
|
|
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
|
queue: ["A", "B"],
|
|
});
|
|
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
|
await playingAt("/media/A", 2995);
|
|
endMedia(); // the up-next card is up, the item is finished, and the mark comes back refused
|
|
await tick();
|
|
progress.mockClear();
|
|
|
|
// The viewer closes the tab while the failed-mark toast is still up.
|
|
act(() => void window.dispatchEvent(new Event("pagehide")));
|
|
expect(beacon).not.toHaveBeenCalled();
|
|
|
|
// The refusal dropped the cached detail — and the open player's own observer refetches it, so
|
|
// by the time the viewer leaves there is an entry again. `settle` must not re-assert watched
|
|
// onto it: only the server is honest about this item now.
|
|
act(() => void qc.setQueryData(qk.plexItem("A", "en"), item("A")));
|
|
|
|
// And leaving the player: `settle` must neither post the end position nor re-assert watched.
|
|
unmount();
|
|
await tick();
|
|
expect(progress.mock.calls.filter(([id]) => id === "A")).toEqual([]);
|
|
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
|
|
});
|
|
|
|
it("restores nothing when the media ended short of the duration Plex reports", async () => {
|
|
// Plex's metadata can overrun the file. Ending at 2900 of a reported 3000 is outside the finish
|
|
// margin, so the duration-based guard alone wrote 2900 back as the resume point: an unwatched
|
|
// item with a ~99% bar that reopened in its last seconds. The media's own `ended` decides.
|
|
prefs({ upNext: false });
|
|
const { progress } = mount({
|
|
details: { A: item("A"), B: item("B") },
|
|
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
|
queue: ["A", "B"],
|
|
});
|
|
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
|
await playingAt("/media/A", 2900);
|
|
endMedia();
|
|
await screen.findByText("Title B");
|
|
await tick();
|
|
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
|
});
|
|
|
|
it("does restore the position when the refused finish came from the credits skip", async () => {
|
|
// The other caller of the same helper captures a MID-episode position, and there the rollback
|
|
// is the point: without it the viewer loses the stretch they watched. One helper, two callers,
|
|
// and only the near-end one must hold back.
|
|
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
|
|
const withCredits = {
|
|
...item("A"),
|
|
markers: [{ type: "credits", start_s: 2000, end_s: 2990 }],
|
|
} as unknown as PlexItemDetail;
|
|
const { qc, progress } = mount({
|
|
details: { A: withCredits, B: item("B") },
|
|
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
|
queue: ["A", "B"],
|
|
});
|
|
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
|
await playingAt("/media/A", 2010); // inside the credits → auto-skip advances and marks watched
|
|
await screen.findByText("Title B");
|
|
await tick();
|
|
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
|
|
["A", 2010, 3000, true],
|
|
]);
|
|
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
|
|
});
|
|
|
|
it("records a second viewing when the viewer plays on from the finished frame", async () => {
|
|
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
|
|
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
|
|
// discarded a whole rewatch with the gate green (round 5).
|
|
prefs({ upNext: true });
|
|
const { progress } = mount({
|
|
details: { A: item("A"), B: item("B") },
|
|
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
|
queue: ["A", "B"],
|
|
});
|
|
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
|
await playingAt("/media/A", 2995);
|
|
endMedia(); // the up-next card is up; the item is finished
|
|
await tick();
|
|
progress.mockClear();
|
|
|
|
// The viewer cancels the card and rewinds: playing again takes the finish back.
|
|
const el = video() as Media;
|
|
Object.defineProperty(el, "ended", { configurable: true, get: () => false });
|
|
act(() => void el.dispatchEvent(new Event("play")));
|
|
el._t = 2400;
|
|
act(() => void el.dispatchEvent(new Event("timeupdate")));
|
|
act(() => void el.dispatchEvent(new Event("pause")));
|
|
await tick();
|
|
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
|
|
["A", 2400, 3000, true],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("restarting the session before playback has ticked", () => {
|
|
it("an audio switch or a keyboard seek keeps the resume point", async () => {
|
|
const twoAudio = {
|
|
...item("A", 1200),
|
|
audio_streams: [
|
|
{ ord: 0, label: "English", language: "en", default: true },
|
|
{ ord: 1, label: "Magyar", language: "hu", default: false },
|
|
],
|
|
} as unknown as PlexItemDetail;
|
|
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
|
|
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
|
|
fireEvent.keyDown(window, { key: "a" });
|
|
fireEvent.keyDown(window, { key: "ArrowRight" });
|
|
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
|
|
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
|
|
});
|
|
});
|
|
|
|
it("a seek made while a restart is pending wins over it", async () => {
|
|
let calls = 0;
|
|
const pending = deferred();
|
|
mount({
|
|
details: { A: item("A") },
|
|
sessions: {
|
|
A: () => {
|
|
calls++;
|
|
if (calls === 1) return Promise.resolve(direct("A"));
|
|
return calls === 2 ? pending.promise : new Promise(() => {});
|
|
},
|
|
},
|
|
});
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
|
Object.defineProperty(video(), "seekable", { get: () => ({ length: 1, end: () => 100 }) });
|
|
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 act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
|
|
await tick();
|
|
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
|
|
});
|
|
|
|
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") },
|
|
sessions: { A: () => Promise.resolve(direct("A")), B: () => Promise.resolve(direct("B")) },
|
|
queue: ["A", "B"],
|
|
});
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
|
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/B"));
|
|
act(() => void video().dispatchEvent(new Event("loadedmetadata")));
|
|
expect(video().currentTime).toBe(0); // not A's resume point, 1200
|
|
});
|
|
|
|
it("a refetch of the playing item's detail does not restart it", async () => {
|
|
// react-query refetches it on reconnect, and the in-player info panel invalidates it after a
|
|
// watched toggle — each one restarted the film at the last saved checkpoint.
|
|
const details = { A: item("A", 100) };
|
|
const { qc } = mount({ details, sessions: { A: () => Promise.resolve(direct("A")) } });
|
|
await playingAt("/media/A", 1500);
|
|
details.A = item("A", 1490);
|
|
await act(async () => {
|
|
await qc.invalidateQueries({ queryKey: qk.plexItem("A") });
|
|
});
|
|
await tick();
|
|
expect(vi.mocked(api.plexItem).mock.calls.length).toBeGreaterThan(1); // it did refetch
|
|
expect(startsAsked("A")).toEqual([100]);
|
|
});
|
|
|
|
describe("a session request that fails", () => {
|
|
it("is not reported when it has been superseded", async () => {
|
|
const slowB = deferredFailure();
|
|
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.reject(new HttpError(404)));
|
|
await tick();
|
|
expect(screen.queryByText("plex.player.errNotFound")).toBeNull();
|
|
});
|
|
|
|
it("keeps following the old stream when a seek-restart fails", async () => {
|
|
let calls = 0;
|
|
const { progress, unmount } = mount({
|
|
details: { A: item("A") },
|
|
sessions: {
|
|
A: () =>
|
|
calls++ === 0 ? Promise.resolve(direct("A")) : Promise.reject(new HttpError(500)),
|
|
},
|
|
});
|
|
await playingAt("/media/A", 1500);
|
|
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step
|
|
await screen.findByText("plex.player.errGeneric");
|
|
await playingAt("/media/A", 1600); // the old stream plays on
|
|
unmount();
|
|
expect(progress.mock.calls).toContainEqual(["A", 1600, 3000, true]);
|
|
});
|
|
|
|
it("saves nothing of the previous item's position when moving to the next one fails", async () => {
|
|
const { progress, unmount } = mount({
|
|
details: { A: item("A"), B: item("B") },
|
|
sessions: {
|
|
A: () => Promise.resolve(direct("A")),
|
|
B: () => Promise.reject(new HttpError(404)),
|
|
},
|
|
queue: ["A", "B"],
|
|
});
|
|
await playingAt("/media/A", 1500);
|
|
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
|
await screen.findByText("plex.player.errNotFound");
|
|
await tick();
|
|
await playingAt("/media/A", 1600); // A's media, still attached, ticks once more
|
|
unmount();
|
|
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("a session answer that lands while the next item's detail is still loading is dropped", 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"));
|
|
const real = vi.mocked(api.plexItem).getMockImplementation()!;
|
|
vi.mocked(api.plexItem).mockImplementation((id: string, lang?: string) =>
|
|
id === "C" ? new Promise(() => {}) : real(id, lang),
|
|
);
|
|
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
|
await waitFor(() => expect(startsAsked("B")).toHaveLength(1));
|
|
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
|
await tick();
|
|
await act(async () => slowB.resolve(direct("B")));
|
|
await tick();
|
|
expect(video().getAttribute("src")).not.toBe("/media/B");
|
|
});
|