fix(plex): the player starts, and resumes after a session-restart seek, when React mounts it twice
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.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// @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"));
|
||||
});
|
||||
});
|
||||
@@ -428,7 +428,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
|
||||
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
|
||||
// play() can't leave audio playing on a detached element after leaving the player.
|
||||
//
|
||||
// The setup half matters as much as the cleanup: React may run the cleanup and then MOUNT AGAIN
|
||||
// (StrictMode does exactly that in development). With the flag only ever lowered, it stayed false
|
||||
// for the life of the player — every play() is gated on it, so the player never started by itself,
|
||||
// and a seek that restarts the session left it paused (bug hunt 2026-09, found on localdev).
|
||||
useEffect(() => {
|
||||
aliveRef.current = true;
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
if (hlsRef.current) {
|
||||
|
||||
Reference in New Issue
Block a user