A poll already out when the player closed answered with the point the server held before the closing beacon, over the row's patch; Continue then opened there and wrote it back. The close now cancels the listings' fetches before patching and reads them again once the beacon has landed.
234 lines
8.8 KiB
TypeScript
234 lines
8.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// 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 as rtlRender, cleanup } from "@testing-library/react";
|
|
import type { ReactElement } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
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 { api, type DownloadJob } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
|
|
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,
|
|
},
|
|
});
|
|
|
|
// The player writes its resume point into the library listings' cache, so it sits under a client.
|
|
const render = (ui: ReactElement, client = new QueryClient()) =>
|
|
rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
/** The rendered <video>, given a length and a clock the test sets (jsdom plays nothing). */
|
|
function loaded(duration = 600) {
|
|
const video = document.body.querySelector("video")!;
|
|
let t = 0;
|
|
Object.defineProperties(video, {
|
|
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
|
duration: { get: () => duration, configurable: true },
|
|
readyState: { get: () => 1, configurable: true },
|
|
});
|
|
video.dispatchEvent(new Event("loadedmetadata"));
|
|
return video;
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
it("opens focused on its panel, so Space plays rather than pressing the dialog's ✕", () => {
|
|
// jsdom lays nothing out, so every element reads as hidden (`offsetParent` null) and a dialog
|
|
// falls back to its panel whatever it asked for; as in a browser, the ✕ is on screen here.
|
|
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (
|
|
this: HTMLElement,
|
|
) {
|
|
return this.parentElement;
|
|
});
|
|
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
|
|
const panel = document.activeElement as HTMLElement;
|
|
expect(panel.tagName).toBe("DIV");
|
|
expect(panel.contains(document.body.querySelector("video"))).toBe(true);
|
|
});
|
|
|
|
it("opens where the viewer stopped — or from the start when asked", () => {
|
|
const job = { ...withAsset({ width: 1280, height: 720 }), position_seconds: 300 };
|
|
render(<LibraryPlayer job={job} onClose={() => {}} />);
|
|
expect(loaded().currentTime).toBe(300);
|
|
cleanup();
|
|
render(<LibraryPlayer job={job} fromStart onClose={() => {}} />);
|
|
expect(loaded().currentTime).toBe(0);
|
|
});
|
|
|
|
it("keeps where it stopped: on the server, and on the library row at once", () => {
|
|
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
|
|
ok: true,
|
|
job_id: 7,
|
|
position_seconds: 125,
|
|
});
|
|
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
|
|
const client = new QueryClient();
|
|
const job = withAsset({ width: 1280, height: 720 });
|
|
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
|
|
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
|
const video = loaded();
|
|
video.dispatchEvent(new Event("playing"));
|
|
video.currentTime = 125.4;
|
|
video.dispatchEvent(new Event("pause"));
|
|
|
|
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
|
|
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
|
|
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
|
|
// Closing the player sends the point the way that survives the page going away.
|
|
unmount();
|
|
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
|
|
});
|
|
|
|
it("keeps the closing point over a list fetch that read the server before the point landed", async () => {
|
|
// The listing polls. One already out when the player closed answered with the point the server
|
|
// held BEFORE the closing beacon, over the row's patch — and Continue then opened there, and its
|
|
// first checkpoint wrote that older point back over the right one.
|
|
let settled: (() => void) | undefined;
|
|
vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation((_id, _at, _dur, onSettled) => {
|
|
settled = onSettled;
|
|
});
|
|
const client = new QueryClient();
|
|
const job = withAsset({ width: 1280, height: 720 });
|
|
client.setQueryData(qk.downloads(), [{ ...job, position_seconds: 10 }]);
|
|
let answer: (jobs: DownloadJob[]) => void = () => {};
|
|
const poll = client.fetchQuery({
|
|
queryKey: qk.downloads(),
|
|
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
|
|
});
|
|
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
|
const video = loaded();
|
|
video.dispatchEvent(new Event("playing"));
|
|
video.currentTime = 300;
|
|
unmount();
|
|
answer([{ ...job, position_seconds: 10 }]);
|
|
await poll;
|
|
expect(client.getQueryData<DownloadJob[]>(qk.downloads())![0]!.position_seconds).toBe(300);
|
|
// Once the point has landed, the listing asks the server again.
|
|
const invalidate = vi.spyOn(client, "invalidateQueries");
|
|
settled?.();
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.downloads(), exact: true });
|
|
});
|
|
});
|