feat(watch): prototype background sound for a shared video on iPad (dev only)

On iPad Brave a shared mp3 keeps playing when the browser leaves the
foreground, but a shared h264 video stops at once, even with Brave's own
"Enable Background Audio" on. Three variants behind a `?bg=` param on the
/watch page, for a real-iPad comparison; without the param the page is
unchanged.

  a: the sound comes from a hidden <audio> on the same file, the <video> runs
     muted and follows it (drift correction, user seeks and mute moved onto
     the audio; our own catch-up seeks are not copied back, which re-seeked
     the sound on every correction and sent both back seconds in WebKit).
  b: the <video> plays with sound; an <audio> unlocked by the start tap takes
     over while the page is hidden and hands back on return.
  c: no audio trick: the <video> is inserted as a node of its own into an
     already-mounted wrapper. Brave iOS's background play only hooks a video
     present at document start or added as a MutationObserver addedNode that
     IS a video (brave-core ios/browser/web/media/resources/
     media_backgrounding.ts); the page inserted the video inside a new wrapper
     in one go, so Brave never saw it. A re-implementation of that detection
     rule reports the default page's video unhooked and mode c's hooked.

Modes a and b were driven in Chromium and WebKit with a simulated backgrounding
(document.hidden forced plus the OS's pause): sound continues, the picture
catches up on return, a user pause stops both. iPadOS's own media policy can
only be judged on the iPad.
This commit is contained in:
2026-09-14 23:40:51 +02:00
parent 70598d47a3
commit 2c3aaec62d
2 changed files with 223 additions and 9 deletions
+67 -9
View File
@@ -2,6 +2,12 @@ import { useEffect, useRef, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle } from "lucide-react";
import { Button } from "./ui/Button";
import { formatBytes } from "../lib/format";
import {
followAudio,
handOverOnHide,
readBackgroundMode,
startPlayback,
} from "../lib/watchBackgroundAudio";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@@ -130,6 +136,24 @@ export default function WatchPage() {
};
useMediaSession(mediaRef, meta, status === "ready");
// PROTOTYPE (dev only): background sound for a video on iPad, `?bg=a`, `?bg=b` or `?bg=c` — see
// lib/watchBackgroundAudio. Without the param the page behaves exactly as before.
const [bgMode] = useState(() => readBackgroundMode(window.location.search));
const audioMode = bgMode === "a" || bgMode === "b" ? bgMode : null;
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const [started, setStarted] = useState(false);
useEffect(() => {
if (!audioMode || !videoEl || !audioEl) return;
return audioMode === "a" ? followAudio(videoEl, audioEl) : handOverOnHide(videoEl, audioEl);
}, [audioMode, videoEl, audioEl]);
// Mode "c": mount the player's wrapper first and the <video> in the NEXT commit, so the video enters
// the DOM as a node of its own — the only way Brave's background play notices it.
const [videoSlot, setVideoSlot] = useState(bgMode !== "c");
useEffect(() => {
if (status === "ready" && !videoSlot) setVideoSlot(true);
}, [status, videoSlot]);
useEffect(() => {
if (!token) {
setStatus("gone");
@@ -264,15 +288,49 @@ export default function WatchPage() {
) : (
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
player instead of a wide box with black bars; a landscape clip fills the width. */
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video
ref={setMedia}
controls
playsInline
src={meta.file_url}
poster={meta.poster_url || undefined}
className="max-h-[80vh] max-w-full mx-auto block"
/>
<div className="relative rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
{videoSlot && (
<video
ref={(el) => {
setVideoEl(el);
// Mode "a": the audio is the clock, so the lock-screen controls drive it instead.
if (bgMode !== "a") setMedia(el);
}}
controls={!audioMode || started}
playsInline
muted={bgMode === "a"}
src={meta.file_url}
poster={meta.poster_url || undefined}
className="max-h-[80vh] max-w-full mx-auto block"
/>
)}
{audioMode && (
<audio
ref={(el) => {
setAudioEl(el);
if (bgMode === "a") setMedia(el);
}}
src={meta.file_url}
preload="auto"
className="hidden"
/>
)}
{audioMode && !started && videoEl && audioEl && (
<button
type="button"
data-testid="bg-start"
onClick={() => {
startPlayback(audioMode, videoEl, audioEl);
setStarted(true);
}}
className="absolute inset-0 grid place-items-center bg-black/30"
>
<PlayCircle className="w-20 h-20 text-white drop-shadow" />
<span className="absolute top-2 left-2 rounded bg-black/60 px-2 py-0.5 text-xs text-white">
bg: {audioMode.toUpperCase()}
</span>
</button>
)}
</div>
)}
<div className="mt-3 flex items-start gap-3">