fix(watch): drop the Web Audio reroute, keep Media Session for iOS background audio (0.57.2)

Reported: on iPad Brave a shared video's audio still stopped when backgrounded, while youtube.com
keeps playing. Root cause: routing the element's audio through createMediaElementSource pulls it out
of the normal media pipeline the browser's background-playback feature (and iOS) hooks into, and iOS
then suspends the AudioContext on background — the opposite of the goal. YouTube uses a plain media
element + Media Session and lets the browser background it. So drop the Web Audio graph entirely and
keep only the Media Session metadata + play/pause handlers, leaving native background handling in
charge. Frontend-only.
This commit is contained in:
2026-07-28 03:29:48 +02:00
parent 9187ae56b1
commit 81d0943fdc
3 changed files with 18 additions and 58 deletions
+1 -1
View File
@@ -1 +1 @@
0.57.1
0.57.2
+10 -57
View File
@@ -5,57 +5,19 @@ import { Download, Link2, Lock, PlayCircle } from "lucide-react";
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
// self-contained i18n dict (the public viewer has no app language preference).
// Keep audio playing when the page is backgrounded (iOS Safari/Brave otherwise suspend a <video>
// the moment you leave the tab — the "background playback" a native app / YouTube Premium gets). Two
// pieces: (1) route the element's audio through a Web Audio graph — once that graph is live, iOS
// keeps the audio session running in the background while the video frame freezes; (2) Media Session
// metadata + handlers, which give lock-screen controls AND signal the OS that media is active. The
// file is same-origin (served by us), so createMediaElementSource isn't CORS-tainted. All of it is
// best-effort: any unsupported bit falls back to the plain <video>. Set up lazily on the first play
// (a user gesture — required to create/resume an AudioContext) and only once (a second
// createMediaElementSource on the same element throws).
function useBackgroundAudio(
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video> +
// MediaSession leaves the browser's native background handling in charge.
function useMediaSession(
videoRef: React.RefObject<HTMLVideoElement | null>,
meta: Meta | null,
ready: boolean,
) {
const ctxRef = useRef<AudioContext | null>(null);
const wiredRef = useRef(false);
useEffect(() => {
const video = videoRef.current;
if (!video || !ready) return;
// Only iOS suspends a backgrounded <video> — and only there does rerouting the audio through
// Web Audio buy anything. Every other platform backgrounds media audio natively, so we leave
// their <video> untouched rather than risk the reroute leaving foreground audio silent if the
// AudioContext can't resume. (iPadOS reports as MacIntel + touch.)
const isIOS =
/iP(hone|ad|od)/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (!isIOS) return;
const wire = () => {
if (wiredRef.current) {
ctxRef.current?.resume().catch(() => {});
return;
}
try {
const Ctx =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctx) return;
const ctx = new Ctx();
ctx.createMediaElementSource(video).connect(ctx.destination);
ctxRef.current = ctx;
wiredRef.current = true;
ctx.resume().catch(() => {});
} catch {
/* unsupported / already wired — plain <video> still plays, just not in the background */
}
};
video.addEventListener("play", wire);
return () => video.removeEventListener("play", wire);
}, [videoRef, ready]);
useEffect(() => {
if (!ready || !meta || !("mediaSession" in navigator)) return;
const video = videoRef.current;
@@ -73,15 +35,6 @@ function useBackgroundAudio(
/* MediaMetadata / handlers unsupported — ignore */
}
}, [ready, meta, videoRef]);
// Recover if the context gets suspended (e.g. iOS suspends it on background) once we're back.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") ctxRef.current?.resume().catch(() => {});
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, []);
}
type Meta = {
@@ -160,7 +113,7 @@ export default function WatchPage() {
const [error, setError] = useState<string | null>(null);
const [unlocking, setUnlocking] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
useBackgroundAudio(videoRef, meta, status === "ready");
useMediaSession(videoRef, meta, status === "ready");
useEffect(() => {
if (!token) {
+7
View File
@@ -14,6 +14,13 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.57.2",
date: "2026-07-28",
chores: [
"Sharing: the shared-video page now advertises playback to the OS via the Media Session API only, dropping a Web Audio workaround that interfered with the browser's own background playback on iOS (it left the audio to be suspended when the tab was backgrounded, the opposite of the goal) — so background audio is left to the browser's native handling, the way it works for youtube.com.",
],
},
{
version: "0.57.1",
date: "2026-07-28",