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";
|
import { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
||||||
|
|
||||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
// 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
|
// 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>
|
||||||
|
// 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 = {
|
type Meta = {
|
||||||
needs_password: boolean;
|
needs_password: boolean;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -80,6 +151,8 @@ export default function WatchPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
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);
|
||||||
|
useBackgroundAudio(videoRef, meta, status === "ready");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
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. */}
|
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">
|
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
src={meta.file_url}
|
src={meta.file_url}
|
||||||
|
|||||||
Reference in New Issue
Block a user