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:
@@ -11,6 +11,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
});
|
});
|
||||||
|
const expose = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||||
|
|
||||||
import LibraryPlayer from "./LibraryPlayer";
|
import LibraryPlayer from "./LibraryPlayer";
|
||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../lib/api";
|
||||||
@@ -90,6 +92,11 @@ describe("LibraryPlayer", () => {
|
|||||||
expect(document.body.querySelector("audio")).toBeNull();
|
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", () => {
|
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
|
||||||
render(
|
render(
|
||||||
<LibraryPlayer
|
<LibraryPlayer
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { jobTitle } from "./downloadRow";
|
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
|
// 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
|
// 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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<video
|
<video
|
||||||
|
ref={exposeVideoToBackgroundPlay}
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
poster={poster}
|
poster={poster}
|
||||||
controls
|
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 PlexPlayer from "./PlexPlayer";
|
||||||
import { api, type PlexItemDetail } from "../lib/api";
|
import { api, type PlexItemDetail } from "../lib/api";
|
||||||
|
|
||||||
@@ -98,4 +101,21 @@ describe("PlexPlayer under StrictMode", () => {
|
|||||||
video.dispatchEvent(new Event("loadedmetadata"));
|
video.dispatchEvent(new Event("loadedmetadata"));
|
||||||
await waitFor(() => expect(played).toContain("/api/plex/direct/99079"));
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../i18n";
|
||||||
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import {
|
import {
|
||||||
@@ -115,7 +116,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
const detail = detailQ.data;
|
const detail = detailQ.data;
|
||||||
const duration = detail?.duration_seconds ?? 0;
|
const duration = detail?.duration_seconds ?? 0;
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
// False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
|
// 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.
|
// 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,
|
prefs.subShadowColor,
|
||||||
)}}`}</style>
|
)}}`}</style>
|
||||||
<video
|
<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
|
// 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
|
// 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.
|
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
|
// 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.
|
// silently drop the audio player and reintroduce the iPad-broken behavior.
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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";
|
import WatchPage from "./WatchPage";
|
||||||
|
|
||||||
function mockMeta(meta: Record<string, unknown>) {
|
function mockMeta(meta: Record<string, unknown>) {
|
||||||
@@ -18,6 +21,50 @@ function mockMeta(meta: Record<string, unknown>) {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.unstubAllGlobals();
|
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)", () => {
|
describe("WatchPage media element (S3 · 5a)", () => {
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { Button } from "./ui/Button";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
import {
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||||
followAudio,
|
import { LS } from "../lib/storage";
|
||||||
handOverOnHide,
|
|
||||||
readBackgroundMode,
|
|
||||||
startPlayback,
|
|
||||||
} from "../lib/watchBackgroundAudio";
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -86,6 +82,7 @@ const STR = {
|
|||||||
file: "File",
|
file: "File",
|
||||||
links: "Links",
|
links: "Links",
|
||||||
brand: "Shared via Siftlode",
|
brand: "Shared via Siftlode",
|
||||||
|
repeat: "Repeat",
|
||||||
},
|
},
|
||||||
hu: {
|
hu: {
|
||||||
loading: "Betöltés…",
|
loading: "Betöltés…",
|
||||||
@@ -99,6 +96,7 @@ const STR = {
|
|||||||
file: "Fájl",
|
file: "Fájl",
|
||||||
links: "Linkek",
|
links: "Linkek",
|
||||||
brand: "Megosztva a Siftlode-dal",
|
brand: "Megosztva a Siftlode-dal",
|
||||||
|
repeat: "Ismétlés",
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
loading: "Wird geladen…",
|
loading: "Wird geladen…",
|
||||||
@@ -112,6 +110,7 @@ const STR = {
|
|||||||
file: "Datei",
|
file: "Datei",
|
||||||
links: "Links",
|
links: "Links",
|
||||||
brand: "Geteilt über Siftlode",
|
brand: "Geteilt über Siftlode",
|
||||||
|
repeat: "Wiederholen",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -136,23 +135,24 @@ export default function WatchPage() {
|
|||||||
};
|
};
|
||||||
useMediaSession(mediaRef, meta, status === "ready");
|
useMediaSession(mediaRef, meta, status === "ready");
|
||||||
|
|
||||||
// PROTOTYPE (dev only): background sound for a video on iPad, `?bg=a`, `?bg=b` or `?bg=c` — see
|
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
|
||||||
// lib/watchBackgroundAudio. Without the param the page behaves exactly as before.
|
// (this page has no account); an unreadable storage just means the default.
|
||||||
const [bgMode] = useState(() => readBackgroundMode(window.location.search));
|
const [repeat, setRepeat] = useState(() => {
|
||||||
const audioMode = bgMode === "a" || bgMode === "b" ? bgMode : null;
|
try {
|
||||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
return localStorage.getItem(LS.watchRepeat) !== "0";
|
||||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
} catch {
|
||||||
const [started, setStarted] = useState(false);
|
return true;
|
||||||
useEffect(() => {
|
}
|
||||||
if (!audioMode || !videoEl || !audioEl) return;
|
});
|
||||||
return audioMode === "a" ? followAudio(videoEl, audioEl) : handOverOnHide(videoEl, audioEl);
|
const toggleRepeat = () => {
|
||||||
}, [audioMode, videoEl, audioEl]);
|
const next = !repeat;
|
||||||
// Mode "c": mount the player's wrapper first and the <video> in the NEXT commit, so the video enters
|
setRepeat(next);
|
||||||
// the DOM as a node of its own — the only way Brave's background play notices it.
|
try {
|
||||||
const [videoSlot, setVideoSlot] = useState(bgMode !== "c");
|
localStorage.setItem(LS.watchRepeat, next ? "1" : "0");
|
||||||
useEffect(() => {
|
} catch {
|
||||||
if (status === "ready" && !videoSlot) setVideoSlot(true);
|
/* storage unavailable — the toggle still applies to this visit */
|
||||||
}, [status, videoSlot]);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
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
|
<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. */
|
the surface (not the black video box) so it looks intentional without artwork. */
|
||||||
<div className="rounded-xl border border-border bg-surface p-4">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
/* 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. */
|
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">
|
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||||
{videoSlot && (
|
<video
|
||||||
<video
|
ref={(el) => {
|
||||||
ref={(el) => {
|
setMedia(el);
|
||||||
setVideoEl(el);
|
// So Brave iOS's background play keeps it going when the app is left.
|
||||||
// Mode "a": the audio is the clock, so the lock-screen controls drive it instead.
|
exposeVideoToBackgroundPlay(el);
|
||||||
if (bgMode !== "a") setMedia(el);
|
}}
|
||||||
}}
|
controls
|
||||||
controls={!audioMode || started}
|
playsInline
|
||||||
playsInline
|
loop={repeat}
|
||||||
muted={bgMode === "a"}
|
src={meta.file_url}
|
||||||
src={meta.file_url}
|
poster={meta.poster_url || undefined}
|
||||||
poster={meta.poster_url || undefined}
|
className="max-h-[80vh] max-w-full mx-auto block"
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 flex items-start gap-3">
|
<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 className="text-sm text-muted">{meta.uploader}</div>
|
||||||
))}
|
))}
|
||||||
</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 && (
|
{meta.allow_download && (
|
||||||
<a
|
<a
|
||||||
href={meta.file_url}
|
href={meta.file_url}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// exposeVideoToBackgroundPlay: Brave iOS's background play hooks only a <video> that shows up as a
|
||||||
|
// MutationObserver addedNode itself. A video mounted inside a new wrapper in one go never does — this
|
||||||
|
// locks that the helper makes it one, once per element, without moving it.
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { exposeVideoToBackgroundPlay } from "./browserBackgroundPlay";
|
||||||
|
|
||||||
|
// Brave's detection rule, re-implemented: count addedNodes that ARE a <video>.
|
||||||
|
async function videosSeenWhile(act: () => void): Promise<number> {
|
||||||
|
let seen = 0;
|
||||||
|
const observer = new MutationObserver((records) => {
|
||||||
|
for (const r of records) r.addedNodes.forEach((n) => n instanceof HTMLVideoElement && seen++);
|
||||||
|
});
|
||||||
|
observer.observe(document, { childList: true, subtree: true });
|
||||||
|
act();
|
||||||
|
await Promise.resolve(); // MutationObserver callbacks are microtasks
|
||||||
|
observer.disconnect();
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountWrapped(): { wrapper: HTMLDivElement; video: HTMLVideoElement } {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
const before = document.createElement("span");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const after = document.createElement("span");
|
||||||
|
wrapper.append(before, video, after);
|
||||||
|
return { wrapper, video };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("exposeVideoToBackgroundPlay", () => {
|
||||||
|
it("a video mounted inside its wrapper is invisible to the rule until exposed", async () => {
|
||||||
|
const { wrapper, video } = mountWrapped();
|
||||||
|
expect(await videosSeenWhile(() => document.body.append(wrapper))).toBe(0);
|
||||||
|
expect(await videosSeenWhile(() => exposeVideoToBackgroundPlay(video))).toBe(1);
|
||||||
|
wrapper.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the video exactly where it was", () => {
|
||||||
|
const { wrapper, video } = mountWrapped();
|
||||||
|
document.body.append(wrapper);
|
||||||
|
exposeVideoToBackgroundPlay(video);
|
||||||
|
expect([...wrapper.children].map((c) => c.tagName)).toEqual(["SPAN", "VIDEO", "SPAN"]);
|
||||||
|
wrapper.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acts once per element, so a ref callback running on every render adds nothing", async () => {
|
||||||
|
const { wrapper, video } = mountWrapped();
|
||||||
|
document.body.append(wrapper);
|
||||||
|
exposeVideoToBackgroundPlay(video);
|
||||||
|
expect(await videosSeenWhile(() => exposeVideoToBackgroundPlay(video))).toBe(0);
|
||||||
|
wrapper.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores null and a detached element", () => {
|
||||||
|
expect(() => exposeVideoToBackgroundPlay(null)).not.toThrow();
|
||||||
|
expect(() => exposeVideoToBackgroundPlay(document.createElement("video"))).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Brave iOS keeps a web <video> playing when the browser leaves the foreground (Settings → Media → "Enable
|
||||||
|
// Background Audio") by re-playing a video the OS paused. But it only hooks the <video> elements it SEES:
|
||||||
|
// those present at document start, or a MutationObserver addedNode that IS a <video> (brave-core
|
||||||
|
// ios/browser/web/media/resources/media_backgrounding.ts). React inserts a player's <video> inside its
|
||||||
|
// freshly mounted wrapper in one go, so the video itself is never an added node and Brave never hooks it —
|
||||||
|
// measured on iPad Brave 2026-09-14: the shared video stopped the moment the app was left; inserted as a
|
||||||
|
// node of its own it kept playing, locked screen included.
|
||||||
|
//
|
||||||
|
// Re-inserting the element at its own position makes it an added node without changing the DOM React
|
||||||
|
// manages. Playback is not interrupted: a media element removed and re-inserted within the same task is
|
||||||
|
// back in the document by the next stable state, so the spec's pause-on-removal never runs.
|
||||||
|
//
|
||||||
|
// Only a <video> needs this: an <audio> already keeps playing in the background on its own.
|
||||||
|
|
||||||
|
const exposed = new WeakSet<HTMLVideoElement>();
|
||||||
|
|
||||||
|
/** Make a mounted <video> visible to the browser's background-play hook. Idempotent per element, so it is
|
||||||
|
* safe from a ref callback that React calls on every render. */
|
||||||
|
export function exposeVideoToBackgroundPlay(video: HTMLVideoElement | null): void {
|
||||||
|
if (!video || exposed.has(video) || !video.parentNode) return;
|
||||||
|
exposed.add(video);
|
||||||
|
video.parentNode.insertBefore(video, video.nextSibling);
|
||||||
|
}
|
||||||
@@ -65,6 +65,9 @@ export const LS = {
|
|||||||
notifSettings: "siftlode.notifSettings",
|
notifSettings: "siftlode.notifSettings",
|
||||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||||||
seenVersion: "siftlode.seenVersion",
|
seenVersion: "siftlode.seenVersion",
|
||||||
|
// The public /watch page's repeat toggle ("0" = off; on by default). Browser-wide, NOT per account:
|
||||||
|
// that page runs outside the app, with no account at all.
|
||||||
|
watchRepeat: "siftlode.watchRepeat",
|
||||||
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
|
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
// Background sound for a shared VIDEO on iPad — PROTOTYPE, dev only (improvement/watch-background-audio).
|
|
||||||
//
|
|
||||||
// iPadOS WebKit (every iPad browser, Brave included) stops a <video> the moment the browser leaves the
|
|
||||||
// foreground, but lets an <audio> keep playing. Measured on iPad Brave 2026-09-14 with Brave's own
|
|
||||||
// "Enable Background Audio" on: the mp3 share kept playing in another app, the h264 share stopped at once.
|
|
||||||
// A page cannot simply START the sound when it is backgrounded (iOS allows starting playback only from a
|
|
||||||
// tap), so two ways to have an <audio> already in hand are tried side by side:
|
|
||||||
//
|
|
||||||
// "a" — the SOUND always comes from a hidden <audio> on the same file and the <video> runs muted,
|
|
||||||
// following it. Backgrounded, only the picture stops; back in front, the picture catches up.
|
|
||||||
// "b" — the <video> plays with its own sound; an <audio> is unlocked by the start tap and takes over
|
|
||||||
// from the same second when the page is backgrounded, handing back when it returns.
|
|
||||||
//
|
|
||||||
// Both need the start tap to call play() synchronously (startPlayback), so iOS counts it as the user's.
|
|
||||||
//
|
|
||||||
// "c" — no audio trick at all: the <video> is inserted as a node of its own into an already-mounted
|
|
||||||
// wrapper. Brave iOS's background play (brave-core ios/browser/web/media/resources/
|
|
||||||
// media_backgrounding.ts) restarts a <video> the OS paused, but it only hooks videos present at
|
|
||||||
// document start or added as a MutationObserver addedNode that IS a video — the page used to
|
|
||||||
// insert the video inside a new wrapper in one go, so Brave never saw it. Brave-only by nature.
|
|
||||||
|
|
||||||
export type AudioMode = "a" | "b";
|
|
||||||
export type BackgroundMode = AudioMode | "c";
|
|
||||||
|
|
||||||
export function readBackgroundMode(search: string): BackgroundMode | null {
|
|
||||||
const v = new URLSearchParams(search).get("bg");
|
|
||||||
return v === "a" || v === "b" || v === "c" ? v : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The start tap. Must run inside the click handler: iOS allows audible playback only from a gesture. */
|
|
||||||
export function startPlayback(mode: AudioMode, video: HTMLVideoElement, audio: HTMLAudioElement) {
|
|
||||||
audio.currentTime = video.currentTime;
|
|
||||||
if (mode === "a") {
|
|
||||||
video.muted = true;
|
|
||||||
void audio.play();
|
|
||||||
void video.play();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// "b": unlock the audio element with a silent play/pause inside the gesture, then play the video.
|
|
||||||
audio.muted = true;
|
|
||||||
void audio.play().then(() => {
|
|
||||||
audio.pause();
|
|
||||||
audio.muted = false;
|
|
||||||
});
|
|
||||||
void video.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The OS pauses the video when the page goes to the background, and the pause event can arrive before
|
|
||||||
// visibilitychange. Decide what a pause meant only once that has had a moment to land.
|
|
||||||
const SETTLE_MS = 250;
|
|
||||||
const DRIFT_S = 0.3;
|
|
||||||
|
|
||||||
/** Mode "a": the audio is the clock; the muted video follows it. Returns a cleanup. */
|
|
||||||
export function followAudio(video: HTMLVideoElement, audio: HTMLAudioElement): () => void {
|
|
||||||
let timer: number | undefined;
|
|
||||||
const onVideoPlay = () => {
|
|
||||||
if (audio.paused) void audio.play();
|
|
||||||
};
|
|
||||||
const onVideoPause = () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
timer = window.setTimeout(() => {
|
|
||||||
// A pause while the page is in front is the user's; one while hidden is the OS stopping the picture.
|
|
||||||
if (!document.hidden && video.paused) audio.pause();
|
|
||||||
}, SETTLE_MS);
|
|
||||||
};
|
|
||||||
// Our own catch-up seeks on the video must NOT be copied back onto the audio: that loop re-seeked the
|
|
||||||
// sound on every drift correction (an audible skip), and in WebKit it sent both back seconds on return.
|
|
||||||
// Only a seek the user makes on the video's controls moves the sound.
|
|
||||||
let ownSeek = false;
|
|
||||||
const syncVideo = () => {
|
|
||||||
ownSeek = true;
|
|
||||||
video.currentTime = audio.currentTime;
|
|
||||||
};
|
|
||||||
const onVideoSeeking = () => {
|
|
||||||
if (!ownSeek) audio.currentTime = video.currentTime;
|
|
||||||
};
|
|
||||||
const onVideoSeeked = () => {
|
|
||||||
ownSeek = false;
|
|
||||||
};
|
|
||||||
const onVideoVolume = () => {
|
|
||||||
// The controls' mute button acts on the sound; the picture itself always stays muted.
|
|
||||||
if (!video.muted) {
|
|
||||||
audio.muted = !audio.muted;
|
|
||||||
video.muted = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onAudioEnded = () => video.pause();
|
|
||||||
const onVisible = () => {
|
|
||||||
if (document.hidden) return;
|
|
||||||
syncVideo();
|
|
||||||
if (!audio.paused) void video.play();
|
|
||||||
};
|
|
||||||
const drift = window.setInterval(() => {
|
|
||||||
if (!document.hidden && !audio.paused && !video.seeking) {
|
|
||||||
if (Math.abs(video.currentTime - audio.currentTime) > DRIFT_S) syncVideo();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
video.addEventListener("play", onVideoPlay);
|
|
||||||
video.addEventListener("pause", onVideoPause);
|
|
||||||
video.addEventListener("seeking", onVideoSeeking);
|
|
||||||
video.addEventListener("seeked", onVideoSeeked);
|
|
||||||
video.addEventListener("volumechange", onVideoVolume);
|
|
||||||
audio.addEventListener("ended", onAudioEnded);
|
|
||||||
document.addEventListener("visibilitychange", onVisible);
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
window.clearInterval(drift);
|
|
||||||
video.removeEventListener("play", onVideoPlay);
|
|
||||||
video.removeEventListener("pause", onVideoPause);
|
|
||||||
video.removeEventListener("seeking", onVideoSeeking);
|
|
||||||
video.removeEventListener("seeked", onVideoSeeked);
|
|
||||||
video.removeEventListener("volumechange", onVideoVolume);
|
|
||||||
audio.removeEventListener("ended", onAudioEnded);
|
|
||||||
document.removeEventListener("visibilitychange", onVisible);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mode "b": the video plays with sound; the audio takes over while the page is hidden. Returns a cleanup. */
|
|
||||||
export function handOverOnHide(video: HTMLVideoElement, audio: HTMLAudioElement): () => void {
|
|
||||||
// Whether the USER has the video playing. The OS's own pause on backgrounding must not clear it, so
|
|
||||||
// a pause only counts once it has settled with the page still in front.
|
|
||||||
let playing = !video.paused;
|
|
||||||
let timer: number | undefined;
|
|
||||||
const onVideoPlay = () => {
|
|
||||||
playing = true;
|
|
||||||
};
|
|
||||||
const onVideoPause = () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
timer = window.setTimeout(() => {
|
|
||||||
if (!document.hidden && video.paused) playing = false;
|
|
||||||
}, SETTLE_MS);
|
|
||||||
};
|
|
||||||
const onVisibility = () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
if (!playing) return;
|
|
||||||
audio.currentTime = video.currentTime; // where the picture stopped, even if the OS paused it first
|
|
||||||
void audio.play();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!audio.paused) {
|
|
||||||
video.currentTime = audio.currentTime;
|
|
||||||
audio.pause();
|
|
||||||
void video.play();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
video.addEventListener("play", onVideoPlay);
|
|
||||||
video.addEventListener("pause", onVideoPause);
|
|
||||||
document.addEventListener("visibilitychange", onVisibility);
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
video.removeEventListener("play", onVideoPlay);
|
|
||||||
video.removeEventListener("pause", onVideoPause);
|
|
||||||
document.removeEventListener("visibilitychange", onVisibility);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user