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>.
60 lines
2.5 KiB
TypeScript
60 lines
2.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// exposeVideoToBackgroundPlay: Brave iOS's background play hooks only a <video> that shows up as a
|
|
// MutationObserver addedNode itself. A video mounted inside a new wrapper in one go never does — this
|
|
// locks that the helper makes it one, once per element, without moving it.
|
|
import { describe, it, expect } from "vitest";
|
|
import { exposeVideoToBackgroundPlay } from "./browserBackgroundPlay";
|
|
|
|
// Brave's detection rule, re-implemented: count addedNodes that ARE a <video>.
|
|
async function videosSeenWhile(act: () => void): Promise<number> {
|
|
let seen = 0;
|
|
const observer = new MutationObserver((records) => {
|
|
for (const r of records) r.addedNodes.forEach((n) => n instanceof HTMLVideoElement && seen++);
|
|
});
|
|
observer.observe(document, { childList: true, subtree: true });
|
|
act();
|
|
await Promise.resolve(); // MutationObserver callbacks are microtasks
|
|
observer.disconnect();
|
|
return seen;
|
|
}
|
|
|
|
function mountWrapped(): { wrapper: HTMLDivElement; video: HTMLVideoElement } {
|
|
const wrapper = document.createElement("div");
|
|
const before = document.createElement("span");
|
|
const video = document.createElement("video");
|
|
const after = document.createElement("span");
|
|
wrapper.append(before, video, after);
|
|
return { wrapper, video };
|
|
}
|
|
|
|
describe("exposeVideoToBackgroundPlay", () => {
|
|
it("a video mounted inside its wrapper is invisible to the rule until exposed", async () => {
|
|
const { wrapper, video } = mountWrapped();
|
|
expect(await videosSeenWhile(() => document.body.append(wrapper))).toBe(0);
|
|
expect(await videosSeenWhile(() => exposeVideoToBackgroundPlay(video))).toBe(1);
|
|
wrapper.remove();
|
|
});
|
|
|
|
it("keeps the video exactly where it was", () => {
|
|
const { wrapper, video } = mountWrapped();
|
|
document.body.append(wrapper);
|
|
exposeVideoToBackgroundPlay(video);
|
|
expect([...wrapper.children].map((c) => c.tagName)).toEqual(["SPAN", "VIDEO", "SPAN"]);
|
|
wrapper.remove();
|
|
});
|
|
|
|
it("acts once per element, so a ref callback running on every render adds nothing", async () => {
|
|
const { wrapper, video } = mountWrapped();
|
|
document.body.append(wrapper);
|
|
exposeVideoToBackgroundPlay(video);
|
|
expect(await videosSeenWhile(() => exposeVideoToBackgroundPlay(video))).toBe(0);
|
|
wrapper.remove();
|
|
});
|
|
|
|
it("ignores null and a detached element", () => {
|
|
expect(() => exposeVideoToBackgroundPlay(null)).not.toThrow();
|
|
expect(() => exposeVideoToBackgroundPlay(document.createElement("video"))).not.toThrow();
|
|
});
|
|
});
|