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