Files
siftlode/frontend/src/lib/browserBackgroundPlay.dom.test.ts
T
peter 4ad7183102 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>.
2026-09-14 23:58:30 +02:00

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();
});
});