feat(players): keep a video playing in the background on Brave iOS; repeat on the watch page
On iPad Brave, a shared video stopped the moment the browser left the foreground, even with Brave's "Enable Background Audio" on, while an mp3 kept playing. Brave iOS keeps a web <video> going by re-playing one the OS paused (brave-core ios/browser/web/media/resources/media_backgrounding.ts), but it only hooks a <video> present at document start or added as a MutationObserver addedNode that IS a video. React inserts a player's <video> inside its freshly mounted wrapper in one go, so Brave never saw it. lib/browserBackgroundPlay re-inserts a mounted <video> at its own position, once per element: it becomes an added node, the DOM React manages does not change, and playback is not interrupted. The watch page, the downloads library player and the Plex player hand their <video> to it. Measured on the owner's iPad (Brave) with a prototype of the same idea: the video kept playing in another app and with the screen locked. The YouTube modal is not ours to change: its <video> lives in YouTube's cross-origin iframe, and nothing of ours pauses it on backgrounding. The prototype's two audio-element variants (a muted video following an <audio>, and an <audio> taking over on hide) went silent in the background on the iPad and are removed. The watch page gets a Repeat toggle, on by default and remembered per browser (LS.watchRepeat; the page has no account): it sets `loop` on the <video> or the audio-only <audio>.
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import {
|
||||
followAudio,
|
||||
handOverOnHide,
|
||||
readBackgroundMode,
|
||||
startPlayback,
|
||||
} from "../lib/watchBackgroundAudio";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { LS } from "../lib/storage";
|
||||
|
||||
// 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
|
||||
@@ -86,6 +82,7 @@ const STR = {
|
||||
file: "File",
|
||||
links: "Links",
|
||||
brand: "Shared via Siftlode",
|
||||
repeat: "Repeat",
|
||||
},
|
||||
hu: {
|
||||
loading: "Betöltés…",
|
||||
@@ -99,6 +96,7 @@ const STR = {
|
||||
file: "Fájl",
|
||||
links: "Linkek",
|
||||
brand: "Megosztva a Siftlode-dal",
|
||||
repeat: "Ismétlés",
|
||||
},
|
||||
de: {
|
||||
loading: "Wird geladen…",
|
||||
@@ -112,6 +110,7 @@ const STR = {
|
||||
file: "Datei",
|
||||
links: "Links",
|
||||
brand: "Geteilt über Siftlode",
|
||||
repeat: "Wiederholen",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -136,23 +135,24 @@ export default function WatchPage() {
|
||||
};
|
||||
useMediaSession(mediaRef, meta, status === "ready");
|
||||
|
||||
// PROTOTYPE (dev only): background sound for a video on iPad, `?bg=a`, `?bg=b` or `?bg=c` — see
|
||||
// lib/watchBackgroundAudio. Without the param the page behaves exactly as before.
|
||||
const [bgMode] = useState(() => readBackgroundMode(window.location.search));
|
||||
const audioMode = bgMode === "a" || bgMode === "b" ? bgMode : null;
|
||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!audioMode || !videoEl || !audioEl) return;
|
||||
return audioMode === "a" ? followAudio(videoEl, audioEl) : handOverOnHide(videoEl, audioEl);
|
||||
}, [audioMode, videoEl, audioEl]);
|
||||
// Mode "c": mount the player's wrapper first and the <video> in the NEXT commit, so the video enters
|
||||
// the DOM as a node of its own — the only way Brave's background play notices it.
|
||||
const [videoSlot, setVideoSlot] = useState(bgMode !== "c");
|
||||
useEffect(() => {
|
||||
if (status === "ready" && !videoSlot) setVideoSlot(true);
|
||||
}, [status, videoSlot]);
|
||||
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
|
||||
// (this page has no account); an unreadable storage just means the default.
|
||||
const [repeat, setRepeat] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(LS.watchRepeat) !== "0";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const toggleRepeat = () => {
|
||||
const next = !repeat;
|
||||
setRepeat(next);
|
||||
try {
|
||||
localStorage.setItem(LS.watchRepeat, next ? "1" : "0");
|
||||
} catch {
|
||||
/* storage unavailable — the toggle still applies to this visit */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -283,54 +283,31 @@ export default function WatchPage() {
|
||||
<audio>. No poster exists for these, so present it as a deliberate padded bar on
|
||||
the surface (not the black video box) so it looks intentional without artwork. */
|
||||
<div className="rounded-xl border border-border bg-surface p-4">
|
||||
<audio ref={setMedia} controls src={meta.file_url} className="w-full block" />
|
||||
<audio
|
||||
ref={setMedia}
|
||||
controls
|
||||
loop={repeat}
|
||||
src={meta.file_url}
|
||||
className="w-full block"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
||||
player instead of a wide box with black bars; a landscape clip fills the width. */
|
||||
<div className="relative rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||
{videoSlot && (
|
||||
<video
|
||||
ref={(el) => {
|
||||
setVideoEl(el);
|
||||
// Mode "a": the audio is the clock, so the lock-screen controls drive it instead.
|
||||
if (bgMode !== "a") setMedia(el);
|
||||
}}
|
||||
controls={!audioMode || started}
|
||||
playsInline
|
||||
muted={bgMode === "a"}
|
||||
src={meta.file_url}
|
||||
poster={meta.poster_url || undefined}
|
||||
className="max-h-[80vh] max-w-full mx-auto block"
|
||||
/>
|
||||
)}
|
||||
{audioMode && (
|
||||
<audio
|
||||
ref={(el) => {
|
||||
setAudioEl(el);
|
||||
if (bgMode === "a") setMedia(el);
|
||||
}}
|
||||
src={meta.file_url}
|
||||
preload="auto"
|
||||
className="hidden"
|
||||
/>
|
||||
)}
|
||||
{audioMode && !started && videoEl && audioEl && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bg-start"
|
||||
onClick={() => {
|
||||
startPlayback(audioMode, videoEl, audioEl);
|
||||
setStarted(true);
|
||||
}}
|
||||
className="absolute inset-0 grid place-items-center bg-black/30"
|
||||
>
|
||||
<PlayCircle className="w-20 h-20 text-white drop-shadow" />
|
||||
<span className="absolute top-2 left-2 rounded bg-black/60 px-2 py-0.5 text-xs text-white">
|
||||
bg: {audioMode.toUpperCase()}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||
<video
|
||||
ref={(el) => {
|
||||
setMedia(el);
|
||||
// So Brave iOS's background play keeps it going when the app is left.
|
||||
exposeVideoToBackgroundPlay(el);
|
||||
}}
|
||||
controls
|
||||
playsInline
|
||||
loop={repeat}
|
||||
src={meta.file_url}
|
||||
poster={meta.poster_url || undefined}
|
||||
className="max-h-[80vh] max-w-full mx-auto block"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex items-start gap-3">
|
||||
@@ -350,6 +327,16 @@ export default function WatchPage() {
|
||||
<div className="text-sm text-muted">{meta.uploader}</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant={repeat ? "outline-accent" : "outline"}
|
||||
size="md"
|
||||
icon={<Repeat className="w-4 h-4" />}
|
||||
aria-pressed={repeat}
|
||||
onClick={toggleRepeat}
|
||||
className="shrink-0"
|
||||
>
|
||||
{T.repeat}
|
||||
</Button>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
|
||||
Reference in New Issue
Block a user