feat(watch): keep audio playing in the background on the shared watch page (S4)

A shared /watch video stopped the moment you backgrounded the browser on iOS (Safari/Brave suspend a
<video>), while an audio-only share kept playing — the user wanted YouTube-style background playback
for video too. Route the element's same-origin audio through a Web Audio graph (once that graph is
live, iOS keeps the audio session running in the background while the frame freezes) and add Media
Session metadata + play/pause handlers for lock-screen controls. Set up lazily on first play (the
gesture an AudioContext needs) and only once. Best-effort — any unsupported bit falls back to the
plain <video>. Background continuation itself is an iOS behaviour only real-device UAT can confirm.
This commit is contained in:
2026-07-28 02:29:19 +02:00
parent 9d3d69f0e9
commit d5c9fb9918
+75 -1
View File
@@ -1,10 +1,81 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Download, Link2, Lock, PlayCircle } from "lucide-react";
// 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
// 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(
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;
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;
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
: [],
});
navigator.mediaSession.setActionHandler("play", () => void video?.play());
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
} catch {
/* 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 = {
needs_password: boolean;
title?: string | null;
@@ -80,6 +151,8 @@ export default function WatchPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [unlocking, setUnlocking] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
useBackgroundAudio(videoRef, meta, status === "ready");
useEffect(() => {
if (!token) {
@@ -176,6 +249,7 @@ export default function WatchPage() {
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={videoRef}
controls
playsInline
src={meta.file_url}