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:
@@ -0,0 +1,59 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user