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
@@ -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();
});
});
+23
View File
@@ -0,0 +1,23 @@
// Brave iOS keeps a web <video> playing when the browser leaves the foreground (Settings → Media → "Enable
// Background Audio") by re-playing a video the OS paused. But it only hooks the <video> elements it SEES:
// those present at document start, or a MutationObserver addedNode that IS a <video> (brave-core
// ios/browser/web/media/resources/media_backgrounding.ts). React inserts a player's <video> inside its
// freshly mounted wrapper in one go, so the video itself is never an added node and Brave never hooks it —
// measured on iPad Brave 2026-09-14: the shared video stopped the moment the app was left; inserted as a
// node of its own it kept playing, locked screen included.
//
// Re-inserting the element at its own position makes it an added node without changing the DOM React
// manages. Playback is not interrupted: a media element removed and re-inserted within the same task is
// back in the document by the next stable state, so the spec's pause-on-removal never runs.
//
// Only a <video> needs this: an <audio> already keeps playing in the background on its own.
const exposed = new WeakSet<HTMLVideoElement>();
/** Make a mounted <video> visible to the browser's background-play hook. Idempotent per element, so it is
* safe from a ref callback that React calls on every render. */
export function exposeVideoToBackgroundPlay(video: HTMLVideoElement | null): void {
if (!video || exposed.has(video) || !video.parentNode) return;
exposed.add(video);
video.parentNode.insertBefore(video, video.nextSibling);
}
+3
View File
@@ -65,6 +65,9 @@ export const LS = {
notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed",
seenVersion: "siftlode.seenVersion",
// The public /watch page's repeat toggle ("0" = off; on by default). Browser-wide, NOT per account:
// that page runs outside the app, with no account at all.
watchRepeat: "siftlode.watchRepeat",
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const;
-156
View File
@@ -1,156 +0,0 @@
// Background sound for a shared VIDEO on iPad — PROTOTYPE, dev only (improvement/watch-background-audio).
//
// iPadOS WebKit (every iPad browser, Brave included) stops a <video> the moment the browser leaves the
// foreground, but lets an <audio> keep playing. Measured on iPad Brave 2026-09-14 with Brave's own
// "Enable Background Audio" on: the mp3 share kept playing in another app, the h264 share stopped at once.
// A page cannot simply START the sound when it is backgrounded (iOS allows starting playback only from a
// tap), so two ways to have an <audio> already in hand are tried side by side:
//
// "a" — the SOUND always comes from a hidden <audio> on the same file and the <video> runs muted,
// following it. Backgrounded, only the picture stops; back in front, the picture catches up.
// "b" — the <video> plays with its own sound; an <audio> is unlocked by the start tap and takes over
// from the same second when the page is backgrounded, handing back when it returns.
//
// Both need the start tap to call play() synchronously (startPlayback), so iOS counts it as the user's.
//
// "c" — no audio trick at all: the <video> is inserted as a node of its own into an already-mounted
// wrapper. Brave iOS's background play (brave-core ios/browser/web/media/resources/
// media_backgrounding.ts) restarts a <video> the OS paused, but it only hooks videos present at
// document start or added as a MutationObserver addedNode that IS a video — the page used to
// insert the video inside a new wrapper in one go, so Brave never saw it. Brave-only by nature.
export type AudioMode = "a" | "b";
export type BackgroundMode = AudioMode | "c";
export function readBackgroundMode(search: string): BackgroundMode | null {
const v = new URLSearchParams(search).get("bg");
return v === "a" || v === "b" || v === "c" ? v : null;
}
/** The start tap. Must run inside the click handler: iOS allows audible playback only from a gesture. */
export function startPlayback(mode: AudioMode, video: HTMLVideoElement, audio: HTMLAudioElement) {
audio.currentTime = video.currentTime;
if (mode === "a") {
video.muted = true;
void audio.play();
void video.play();
return;
}
// "b": unlock the audio element with a silent play/pause inside the gesture, then play the video.
audio.muted = true;
void audio.play().then(() => {
audio.pause();
audio.muted = false;
});
void video.play();
}
// The OS pauses the video when the page goes to the background, and the pause event can arrive before
// visibilitychange. Decide what a pause meant only once that has had a moment to land.
const SETTLE_MS = 250;
const DRIFT_S = 0.3;
/** Mode "a": the audio is the clock; the muted video follows it. Returns a cleanup. */
export function followAudio(video: HTMLVideoElement, audio: HTMLAudioElement): () => void {
let timer: number | undefined;
const onVideoPlay = () => {
if (audio.paused) void audio.play();
};
const onVideoPause = () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
// A pause while the page is in front is the user's; one while hidden is the OS stopping the picture.
if (!document.hidden && video.paused) audio.pause();
}, SETTLE_MS);
};
// Our own catch-up seeks on the video must NOT be copied back onto the audio: that loop re-seeked the
// sound on every drift correction (an audible skip), and in WebKit it sent both back seconds on return.
// Only a seek the user makes on the video's controls moves the sound.
let ownSeek = false;
const syncVideo = () => {
ownSeek = true;
video.currentTime = audio.currentTime;
};
const onVideoSeeking = () => {
if (!ownSeek) audio.currentTime = video.currentTime;
};
const onVideoSeeked = () => {
ownSeek = false;
};
const onVideoVolume = () => {
// The controls' mute button acts on the sound; the picture itself always stays muted.
if (!video.muted) {
audio.muted = !audio.muted;
video.muted = true;
}
};
const onAudioEnded = () => video.pause();
const onVisible = () => {
if (document.hidden) return;
syncVideo();
if (!audio.paused) void video.play();
};
const drift = window.setInterval(() => {
if (!document.hidden && !audio.paused && !video.seeking) {
if (Math.abs(video.currentTime - audio.currentTime) > DRIFT_S) syncVideo();
}
}, 1000);
video.addEventListener("play", onVideoPlay);
video.addEventListener("pause", onVideoPause);
video.addEventListener("seeking", onVideoSeeking);
video.addEventListener("seeked", onVideoSeeked);
video.addEventListener("volumechange", onVideoVolume);
audio.addEventListener("ended", onAudioEnded);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearTimeout(timer);
window.clearInterval(drift);
video.removeEventListener("play", onVideoPlay);
video.removeEventListener("pause", onVideoPause);
video.removeEventListener("seeking", onVideoSeeking);
video.removeEventListener("seeked", onVideoSeeked);
video.removeEventListener("volumechange", onVideoVolume);
audio.removeEventListener("ended", onAudioEnded);
document.removeEventListener("visibilitychange", onVisible);
};
}
/** Mode "b": the video plays with sound; the audio takes over while the page is hidden. Returns a cleanup. */
export function handOverOnHide(video: HTMLVideoElement, audio: HTMLAudioElement): () => void {
// Whether the USER has the video playing. The OS's own pause on backgrounding must not clear it, so
// a pause only counts once it has settled with the page still in front.
let playing = !video.paused;
let timer: number | undefined;
const onVideoPlay = () => {
playing = true;
};
const onVideoPause = () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
if (!document.hidden && video.paused) playing = false;
}, SETTLE_MS);
};
const onVisibility = () => {
if (document.hidden) {
if (!playing) return;
audio.currentTime = video.currentTime; // where the picture stopped, even if the OS paused it first
void audio.play();
return;
}
if (!audio.paused) {
video.currentTime = audio.currentTime;
audio.pause();
void video.play();
}
};
video.addEventListener("play", onVideoPlay);
video.addEventListener("pause", onVideoPause);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.clearTimeout(timer);
video.removeEventListener("play", onVideoPlay);
video.removeEventListener("pause", onVideoPause);
document.removeEventListener("visibilitychange", onVisibility);
};
}