Files
siftlode/frontend/src/components/LibraryPlayer.dom.test.tsx
T
peter 4ad7183102 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>.
2026-09-14 23:58:30 +02:00

134 lines
4.2 KiB
TypeScript

// @vitest-environment jsdom
//
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
// is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
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";
const baseJob: DownloadJob = {
id: 7,
status: "done",
progress: 100,
speed_bps: null,
eta_s: null,
phase: null,
error: null,
error_code: null,
error_detail: null,
queue_pos: 0,
created_at: null,
source_kind: "youtube",
source_ref: "abc",
source_url: null,
poster_url: null,
profile_id: null,
spec: {
mode: "av",
max_height: null,
container: null,
audio_format: null,
vcodec: null,
embed_subs: false,
embed_chapters: false,
embed_thumbnail: false,
sponsorblock: false,
},
display_name: "Clip",
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: true,
expired: false,
asset: null,
};
const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJob => ({
...baseJob,
asset: {
id: 10,
status: "ready",
title: "Clip",
uploader: null,
uploader_url: null,
upload_date: null,
duration_s: 12,
size_bytes: 1000,
width: null,
height: null,
container: null,
media_kind: "video",
thumbnail_url: null,
expires_at: null,
...over,
},
});
afterEach(cleanup);
describe("LibraryPlayer", () => {
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
render(
<LibraryPlayer
job={withAsset({ width: 1280, height: 720, container: "mp4" })}
onClose={() => {}}
/>,
);
const video = document.body.querySelector("video");
expect(video).not.toBeNull();
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
// No <audio> element for a video asset.
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
job={withAsset({ media_kind: "audio", container: "m4a" })}
onClose={() => {}}
/>,
);
const audio = document.body.querySelector("audio");
expect(audio).not.toBeNull();
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
expect(document.body.querySelector("video")).toBeNull();
});
it("uses media_kind — an audio upload with an uncommon container still renders <audio>", () => {
// The exact bug the fix targets: the old container whitelist (m4a/mp3/…) would drop an .m4b/.weba
// audio upload into a <video> box. media_kind='audio' is authoritative regardless of container.
render(
<LibraryPlayer
job={withAsset({ media_kind: "audio", container: "m4b" })}
onClose={() => {}}
/>,
);
expect(document.body.querySelector("audio")).not.toBeNull();
expect(document.body.querySelector("video")).toBeNull();
});
it("falls back to the container heuristic for a legacy null media_kind", () => {
// A pre-discriminator asset (media_kind null) still resolves audio vs video from dims+container.
render(
<LibraryPlayer job={withAsset({ media_kind: null, container: "mp3" })} onClose={() => {}} />,
);
expect(document.body.querySelector("audio")).not.toBeNull();
});
});