Files
siftlode/frontend/src/components/PlexPlayer.strictmode.dom.test.tsx
T
peter 4ad7183102 feat(players): keep a video playing in the background on Brave iOS; repeat on the watch page
On iPad Brave, a shared video stopped the moment the browser left the
foreground, even with Brave's "Enable Background Audio" on, while an mp3
kept playing. Brave iOS keeps a web <video> going by re-playing one the OS
paused (brave-core ios/browser/web/media/resources/media_backgrounding.ts),
but it only hooks a <video> present at document start or added as a
MutationObserver addedNode that IS a video. React inserts a player's
<video> inside its freshly mounted wrapper in one go, so Brave never saw it.

lib/browserBackgroundPlay re-inserts a mounted <video> at its own position,
once per element: it becomes an added node, the DOM React manages does not
change, and playback is not interrupted. The watch page, the downloads
library player and the Plex player hand their <video> to it. Measured on the
owner's iPad (Brave) with a prototype of the same idea: the video kept
playing in another app and with the screen locked. The YouTube modal is not
ours to change: its <video> lives in YouTube's cross-origin iframe, and
nothing of ours pauses it on backgrounding.

The prototype's two audio-element variants (a muted video following an
<audio>, and an <audio> taking over on hide) went silent in the background on
the iPad and are removed.

The watch page gets a Repeat toggle, on by default and remembered per
browser (LS.watchRepeat; the page has no account): it sets `loop` on the
<video> or the audio-only <audio>.
2026-09-14 23:58:30 +02:00

122 lines
4.5 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" } }),
};
});
const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
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"));
});
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
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(
<QueryClientProvider client={qc}>
<PlexPlayer itemId="99079" onClose={() => {}} />
</QueryClientProvider>,
);
const video = await waitFor(() => document.querySelector("video")!);
expect(expose).toHaveBeenCalledWith(video);
});
});