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:
@@ -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
|
// 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).
|
// 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>
|
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
|
||||||
// the moment you leave the tab — the "background playback" a native app / YouTube Premium gets). Two
|
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
|
||||||
// pieces: (1) route the element's audio through a Web Audio graph — once that graph is live, iOS
|
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
|
||||||
// keeps the audio session running in the background while the video frame freezes; (2) Media Session
|
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
|
||||||
// metadata + handlers, which give lock-screen controls AND signal the OS that media is active. The
|
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
|
||||||
// file is same-origin (served by us), so createMediaElementSource isn't CORS-tainted. All of it is
|
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
|
||||||
// best-effort: any unsupported bit falls back to the plain <video>. Set up lazily on the first play
|
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video> +
|
||||||
// (a user gesture — required to create/resume an AudioContext) and only once (a second
|
// MediaSession leaves the browser's native background handling in charge.
|
||||||
// createMediaElementSource on the same element throws).
|
function useMediaSession(
|
||||||
function useBackgroundAudio(
|
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>,
|
videoRef: React.RefObject<HTMLVideoElement | null>,
|
||||||
meta: Meta | null,
|
meta: Meta | null,
|
||||||
ready: boolean,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!ready || !meta || !("mediaSession" in navigator)) return;
|
if (!ready || !meta || !("mediaSession" in navigator)) return;
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -73,15 +35,6 @@ function useBackgroundAudio(
|
|||||||
/* MediaMetadata / handlers unsupported — ignore */
|
/* MediaMetadata / handlers unsupported — ignore */
|
||||||
}
|
}
|
||||||
}, [ready, meta, videoRef]);
|
}, [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 = {
|
type Meta = {
|
||||||
@@ -160,7 +113,7 @@ export default function WatchPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
useBackgroundAudio(videoRef, meta, status === "ready");
|
useMediaSession(videoRef, meta, status === "ready");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.57.1",
|
||||||
date: "2026-07-28",
|
date: "2026-07-28",
|
||||||
|
|||||||
Reference in New Issue
Block a user