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>.
This commit is contained in:
2026-09-14 23:58:30 +02:00
parent 2c3aaec62d
commit 4ad7183102
10 changed files with 224 additions and 227 deletions
@@ -21,6 +21,9 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
@@ -98,4 +101,21 @@ describe("PlexPlayer under StrictMode", () => {
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);
});
});