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
+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.