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:
@@ -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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user