The player's "still alive" flag — every play() is gated on it, so a late one cannot start a detached <video> after Back — was only ever LOWERED, in an effect's cleanup. React may run a cleanup and mount again (StrictMode does exactly that in development), and then the flag stayed false for the life of the player: it never started by itself, and a seek back past the loaded region, which restarts the session, left it paused. Found on localdev (Westworld S01E06: playing → seek back → the picture settles, paused). Production does not double-mount, which is why only dev showed it — and why several earlier localdev measurements "stayed paused". The effect now raises the flag in its setup. A new test renders the real player under StrictMode and checks it plays once the media is ready; red without the fix. Verified on localdev: it starts by itself, and keeps playing through a backward session-restart seek.
102 lines
3.7 KiB
TypeScript
102 lines
3.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Plex player must start playing by itself — also when React mounts it, unmounts it and mounts
|
|
// it again, which StrictMode does to every component in development.
|
|
//
|
|
// Bug hunt 2026-09, found on localdev: the player's "still alive" flag (every play() is gated on it,
|
|
// so a late one cannot start a detached <video> after Back) was only ever LOWERED, in an effect's
|
|
// cleanup. StrictMode's dry-run unmount lowered it for good: the player never started on its own, and
|
|
// a seek that restarts the session — back past the loaded region — left it paused. Production does
|
|
// not double-mount, which is why only dev showed it.
|
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
|
import { StrictMode } from "react";
|
|
import { render, cleanup, waitFor } 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";
|
|
|
|
const played: string[] = [];
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
// jsdom has no media pipeline: record what the player asks of the element instead.
|
|
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
|
configurable: true,
|
|
value(this: HTMLMediaElement) {
|
|
played.push(this.getAttribute("src") ?? "");
|
|
return Promise.resolve();
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value() {} });
|
|
Object.defineProperty(HTMLMediaElement.prototype, "load", { configurable: true, value() {} });
|
|
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
|
configurable: true,
|
|
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
played.length = 0;
|
|
});
|
|
|
|
const detail = {
|
|
id: "99079",
|
|
title: "The Adversary",
|
|
type: "episode",
|
|
duration_seconds: 3434,
|
|
status: "new",
|
|
position_seconds: 0,
|
|
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
|
subtitle_streams: [],
|
|
markers: [],
|
|
} as unknown as PlexItemDetail;
|
|
|
|
describe("PlexPlayer under StrictMode", () => {
|
|
it("starts playing by itself once the media is ready", async () => {
|
|
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
|
// A direct-play session: the element gets the file URL and plays on `loadedmetadata`. (hls.js is
|
|
// not supported under jsdom, so this is the path the test can drive; the HLS path gates its
|
|
// play() on the very same flag.)
|
|
vi.spyOn(api, "plexSession").mockResolvedValue({
|
|
mode: "direct",
|
|
url: "/api/plex/direct/99079",
|
|
start_s: 0,
|
|
} as never);
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<StrictMode>
|
|
<QueryClientProvider client={qc}>
|
|
<PlexPlayer itemId="99079" onClose={() => {}} />
|
|
</QueryClientProvider>
|
|
</StrictMode>,
|
|
);
|
|
const video = await waitFor(() => {
|
|
const v = document.querySelector("video");
|
|
expect(v?.getAttribute("src")).toBe("/api/plex/direct/99079");
|
|
return v!;
|
|
});
|
|
video.dispatchEvent(new Event("loadedmetadata"));
|
|
await waitFor(() => expect(played).toContain("/api/plex/direct/99079"));
|
|
});
|
|
});
|