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>.
122 lines
4.5 KiB
TypeScript
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);
|
|
});
|
|
});
|