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
+48 -1
View File
@@ -5,7 +5,10 @@
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
// silently drop the audio player and reintroduce the iPad-broken behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, waitFor, cleanup } from "@testing-library/react";
import { render, waitFor, cleanup, fireEvent } from "@testing-library/react";
const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
import WatchPage from "./WatchPage";
function mockMeta(meta: Record<string, unknown>) {
@@ -18,6 +21,50 @@ function mockMeta(meta: Record<string, unknown>) {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
localStorage.clear();
});
describe("WatchPage background play", () => {
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
window.history.replaceState({}, "", "/watch/tok-bg");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const { container } = render(<WatchPage />);
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
expect(expose).toHaveBeenCalledWith(container.querySelector("video"));
});
});
describe("WatchPage repeat", () => {
it("repeats by default, for a video and for an audio-only asset", async () => {
window.history.replaceState({}, "", "/watch/tok-repeat-video");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const video = render(<WatchPage />);
await waitFor(() => expect(video.container.querySelector("video")).not.toBeNull());
expect(video.container.querySelector("video")!.loop).toBe(true);
cleanup();
window.history.replaceState({}, "", "/watch/tok-repeat-audio");
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
const audio = render(<WatchPage />);
await waitFor(() => expect(audio.container.querySelector("audio")).not.toBeNull());
expect(audio.container.querySelector("audio")!.loop).toBe(true);
});
it("the toggle turns it off, and the choice is remembered for the next visit", async () => {
window.history.replaceState({}, "", "/watch/tok-repeat-toggle");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const first = render(<WatchPage />);
const toggle = await first.findByRole("button", { name: "Repeat" });
expect(toggle.getAttribute("aria-pressed")).toBe("true");
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-pressed")).toBe("false");
expect(first.container.querySelector("video")!.loop).toBe(false);
cleanup();
const second = render(<WatchPage />);
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
expect(second.container.querySelector("video")!.loop).toBe(false);
});
});
describe("WatchPage media element (S3 · 5a)", () => {