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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user