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:
2026-09-14 23:58:30 +02:00
parent 2c3aaec62d
commit 4ad7183102
10 changed files with 224 additions and 227 deletions
@@ -11,6 +11,8 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
import LibraryPlayer from "./LibraryPlayer";
import type { DownloadJob } from "../lib/api";
@@ -90,6 +92,11 @@ describe("LibraryPlayer", () => {
expect(document.body.querySelector("audio")).toBeNull();
});
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", () => {
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
expect(expose).toHaveBeenCalledWith(document.body.querySelector("video"));
});
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
render(
<LibraryPlayer
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { jobTitle } from "./downloadRow";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
@@ -47,6 +48,7 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
</div>
) : (
<video
ref={exposeVideoToBackgroundPlay}
src={fileUrl}
poster={poster}
controls
@@ -21,6 +21,9 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
@@ -98,4 +101,21 @@ describe("PlexPlayer under StrictMode", () => {
video.dispatchEvent(new Event("loadedmetadata"));
await waitFor(() => expect(played).toContain("/api/plex/direct/99079"));
});
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
vi.spyOn(api, "plexSession").mockResolvedValue({
mode: "direct",
url: "/api/plex/direct/99079",
start_s: 0,
} as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexPlayer itemId="99079" onClose={() => {}} />
</QueryClientProvider>,
);
const video = await waitFor(() => document.querySelector("video")!);
expect(expose).toHaveBeenCalledWith(video);
});
});
+7 -2
View File
@@ -3,6 +3,7 @@ import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
@@ -115,7 +116,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const detail = detailQ.data;
const duration = detail?.duration_seconds ?? 0;
const videoRef = useRef<HTMLVideoElement>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
// False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
// can't start a detached <video> playing audio in the background.
@@ -1048,7 +1049,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
prefs.subShadowColor,
)}}`}</style>
<video
ref={videoRef}
ref={(el) => {
videoRef.current = el;
// So Brave iOS's background play keeps it going when the app is left (lib/browserBackgroundPlay).
exposeVideoToBackgroundPlay(el);
}}
// Fill the wrapper (which is sized to the viewport / screen) and letterbox by aspect via
// object-contain. NOT `w-auto h-auto max-*`: that caps the video at its intrinsic resolution
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
+48 -1
View File
@@ -5,7 +5,10 @@
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
// silently drop the audio player and reintroduce the iPad-broken behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, waitFor, cleanup } from "@testing-library/react";
import { render, waitFor, cleanup, fireEvent } from "@testing-library/react";
const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
import WatchPage from "./WatchPage";
function mockMeta(meta: Record<string, unknown>) {
@@ -18,6 +21,50 @@ function mockMeta(meta: Record<string, unknown>) {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
localStorage.clear();
});
describe("WatchPage background play", () => {
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
window.history.replaceState({}, "", "/watch/tok-bg");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const { container } = render(<WatchPage />);
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
expect(expose).toHaveBeenCalledWith(container.querySelector("video"));
});
});
describe("WatchPage repeat", () => {
it("repeats by default, for a video and for an audio-only asset", async () => {
window.history.replaceState({}, "", "/watch/tok-repeat-video");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const video = render(<WatchPage />);
await waitFor(() => expect(video.container.querySelector("video")).not.toBeNull());
expect(video.container.querySelector("video")!.loop).toBe(true);
cleanup();
window.history.replaceState({}, "", "/watch/tok-repeat-audio");
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
const audio = render(<WatchPage />);
await waitFor(() => expect(audio.container.querySelector("audio")).not.toBeNull());
expect(audio.container.querySelector("audio")!.loop).toBe(true);
});
it("the toggle turns it off, and the choice is remembered for the next visit", async () => {
window.history.replaceState({}, "", "/watch/tok-repeat-toggle");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const first = render(<WatchPage />);
const toggle = await first.findByRole("button", { name: "Repeat" });
expect(toggle.getAttribute("aria-pressed")).toBe("true");
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-pressed")).toBe("false");
expect(first.container.querySelector("video")!.loop).toBe(false);
cleanup();
const second = render(<WatchPage />);
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
expect(second.container.querySelector("video")!.loop).toBe(false);
});
});
describe("WatchPage media element (S3 · 5a)", () => {
+55 -68
View File
@@ -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}