The auto-skip countdown and the up-next card pushed an entry without a user gesture (Chrome's Back button skips those), and one pushed under the open shortcuts sheet sat above it, so Back closed the hidden layer.
808 lines
36 KiB
TypeScript
808 lines
36 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Plex player's session lifecycle, rendered for real.
|
|
//
|
|
// 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>
|
|
});
|
|
|
|
// --- leaving a played-through item, restarts, failed requests -----------------------------------
|
|
|
|
/** 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.
|
|
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")));
|
|
};
|
|
|
|
// A layer the player opens BY ITSELF — the auto-skip countdown, the up-next card — takes no history
|
|
// entry. Chrome's Back button skips an entry pushed without a user gesture, and one pushed under an
|
|
// open dialog (the shortcuts sheet) sat above it, so Back closed the hidden layer, not the dialog.
|
|
const overlayPushes = (push: { mock: { calls: unknown[][] } }) =>
|
|
push.mock.calls.filter(([state]) => (state as { _ov?: number } | null)?._ov != null);
|
|
|
|
it("gives the auto-skip countdown no history entry", async () => {
|
|
prefs({ autoSkipIntro: true, autoSkipDelay: 5 });
|
|
const withIntro = {
|
|
...item("A"),
|
|
markers: [{ type: "intro", start_s: 100, end_s: 160, final: false }],
|
|
} as unknown as PlexItemDetail;
|
|
mount({ details: { A: withIntro }, sessions: { A: () => Promise.resolve(direct("A")) } });
|
|
await playingAt("/media/A", 50);
|
|
await tick();
|
|
const push = vi.spyOn(window.history, "pushState");
|
|
await playingAt("/media/A", 110); // inside the intro: the countdown starts
|
|
await waitFor(() => expect(screen.getByText("plex.player.skipIntro")).toBeTruthy());
|
|
await tick();
|
|
expect(overlayPushes(push)).toEqual([]);
|
|
});
|
|
|
|
it("gives the up-next card no history entry", async () => {
|
|
prefs({ upNext: true });
|
|
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);
|
|
await tick();
|
|
const push = vi.spyOn(window.history, "pushState");
|
|
endMedia();
|
|
await screen.findByText("Title B"); // the card names the next item
|
|
await tick();
|
|
expect(overlayPushes(push)).toEqual([]);
|
|
});
|
|
|
|
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, final: true }],
|
|
} 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.
|
|
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. 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 () => {
|
|
// A fix 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. `upNext` is ON here — the default — so the
|
|
// player STAYS on the finished item and those routes are live; the 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, final: true }],
|
|
} 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("lets a credits block that does not end the item play on, its Skip only jumping past it", async () => {
|
|
// An item can carry several credits blocks with scenes between them: the first one moving on
|
|
// to the next item skipped a post-credit scene.
|
|
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
|
const twoCredits = {
|
|
...item("A"),
|
|
markers: [
|
|
{ type: "credits", start_s: 2000, end_s: 2100, final: false },
|
|
{ type: "credits", start_s: 2900, end_s: 2995, final: true },
|
|
],
|
|
} as unknown as PlexItemDetail;
|
|
const { qc } = mount({
|
|
details: { A: twoCredits, 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 playingAt("/media/A", 2010);
|
|
await tick();
|
|
expect(screen.queryByText("Title B")).toBeNull();
|
|
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("B", "en"))).toBeUndefined();
|
|
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
|
|
await waitFor(() => expect(startsAsked("A").slice(-1)).toEqual([2100])); // past the block, still A
|
|
expect(screen.queryByText("Title B")).toBeNull();
|
|
expect(setState).not.toHaveBeenCalled();
|
|
await playingAt("/media/A", 2910); // the final block: the auto-skip moves on
|
|
await screen.findByText("Title B");
|
|
expect(setState).toHaveBeenCalledWith("A", "watched");
|
|
});
|
|
|
|
it("does not leave on its own at credits Plex did not flag final, but its Skip does", async () => {
|
|
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
|
const unflagged = {
|
|
...item("A"),
|
|
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: false }],
|
|
} as unknown as PlexItemDetail;
|
|
mount({
|
|
details: { A: unflagged, 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 playingAt("/media/A", 2910);
|
|
await tick();
|
|
expect(screen.queryByText("Title B")).toBeNull();
|
|
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
|
|
await screen.findByText("Title B");
|
|
expect(setState).toHaveBeenCalledWith("A", "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.
|
|
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;
|
|
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]);
|
|
});
|
|
});
|
|
|
|
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]));
|
|
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") },
|
|
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");
|
|
});
|
|
|
|
describe("the server session the player no longer wants", () => {
|
|
const hlsA = { mode: "hls", url: "/hls/A/index.m3u8", start_s: 0, id: "sid-A" } as Awaited<
|
|
ReturnType<typeof api.plexSession>
|
|
>;
|
|
|
|
it("is stopped by its item and its own id when the page is unloaded", async () => {
|
|
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
|
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
|
act(
|
|
() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: false })),
|
|
);
|
|
expect(stop).toHaveBeenCalledWith("A", "sid-A");
|
|
});
|
|
|
|
it("is stopped the same way when the player closes", async () => {
|
|
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
|
const { unmount } = mount({
|
|
details: { A: item("A") },
|
|
sessions: { A: () => Promise.resolve(hlsA) },
|
|
});
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
|
unmount();
|
|
expect(stop).toHaveBeenCalledWith("A", "sid-A");
|
|
});
|
|
|
|
it("is kept when the page only goes into the back/forward cache", async () => {
|
|
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
|
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
|
act(() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: true })));
|
|
expect(stop).not.toHaveBeenCalled();
|
|
});
|
|
});
|