Files
siftlode/frontend/src/components/WatchPage.dom.test.tsx
T
peter 59b27467ab feat(player): the OS media controls, the basic keys and resume for the library and shared-link players
Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.

The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.

The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
2026-10-06 23:55:41 +02:00

327 lines
14 KiB
TypeScript

// @vitest-environment jsdom
//
// The public watch page must render a native <audio> element for an audio-only asset
// (mp3) and a <video> for everything else. WebKit (all iOS browsers) refuses to play an audio-only
// 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, 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>) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => meta })),
);
}
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);
});
});
// The Download link sat at the button's 32 beside the Repeat chip's 28 — one row, two heights.
describe("WatchPage action row", () => {
it("puts the Download on the toolbar rung, level with the Repeat chip", async () => {
window.history.replaceState({}, "", "/watch/tok-row");
mockMeta({
needs_password: false,
audio: false,
allow_download: true,
file_url: "/f.mp4",
title: "A clip",
});
const view = render(<WatchPage />);
const repeat = await view.findByRole("button", { name: "Repeat" });
const download = view.container.querySelector('a[href="/f.mp4"]')!;
expect(repeat.className).toContain("h-7");
expect(download.className).toContain("h-7");
});
// DESIGN B6: the page the stranger sees wears the app's surfaces — the scheme's ambient
// backdrop (no page fill over the body's), the title and actions on a glass panel, a glass Download.
it("sits on the app's surfaces: backdrop, a glass panel, a glass Download", async () => {
window.history.replaceState({}, "", "/watch/tok-b6");
mockMeta({
needs_password: false,
audio: false,
allow_download: true,
file_url: "/f.mp4",
title: "A clip",
});
const view = render(<WatchPage />);
const title = await view.findByRole("heading", { name: "A clip" });
expect((view.container.firstElementChild as HTMLElement).className).not.toMatch(
/(^|\s)bg-bg(\s|$)/,
);
expect(title.closest(".glass")).toBeTruthy();
const download = view.container.querySelector('a[href="/f.mp4"]')!;
expect(download.className).toContain("glass-card");
});
});
describe("WatchPage media element", () => {
it("renders a native <audio> for an audio-only asset", async () => {
window.history.replaceState({}, "", "/watch/tok-audio");
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
const { container } = render(<WatchPage />);
await waitFor(() => expect(container.querySelector("audio")).not.toBeNull());
expect(container.querySelector("video")).toBeNull();
});
it("renders a <video> for a normal video asset", async () => {
window.history.replaceState({}, "", "/watch/tok-video");
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(container.querySelector("audio")).toBeNull();
});
it("renders an info card + Download for a generic file, no player", async () => {
window.history.replaceState({}, "", "/watch/tok-file");
mockMeta({
needs_password: false,
media_kind: "file",
title: "report.pdf",
container: "pdf",
size_bytes: 2_400_000,
allow_download: true,
file_url: "/f.pdf",
});
const { container, findByText } = render(<WatchPage />);
await findByText("report.pdf");
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector("audio")).toBeNull();
// Type · size chip and a Download link to the file.
await findByText(/PDF · 2\.3 MB/);
const dl = container.querySelector('a[href="/f.pdf"]');
expect(dl).not.toBeNull();
// The page's one action is the filled primary, not a muted outline.
expect(dl!.className).toContain("state-filled");
});
it("omits the Download for a view-only file link", async () => {
window.history.replaceState({}, "", "/watch/tok-file-noview");
mockMeta({
needs_password: false,
media_kind: "file",
title: "secret.zip",
container: "zip",
size_bytes: 1000,
allow_download: false,
file_url: "/f.zip",
});
const { container, findByText } = render(<WatchPage />);
await findByText("secret.zip");
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
});
});
// Only a 404 means a link is gone. Every other failure used to render the permanent "no longer
// available" state too: a server restart, a dropped connection, and the unlock's own rate limit told a
// viewer with a valid link that it had expired or been revoked, with no way to try again.
describe("WatchPage honest failures", () => {
const res = (status: number, body: unknown = {}) => ({
ok: status < 400,
status,
json: async () => body,
});
const GONE = "This link is no longer available.";
it("a server error is a failure with a retry, not a dead link", async () => {
window.history.replaceState({}, "", "/watch/tok-5xx");
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(res(503))
.mockResolvedValue(
res(200, { needs_password: false, file_url: "/f.mp4", title: "A clip" }),
),
);
const page = render(<WatchPage />);
const retry = await page.findByRole("button", { name: "Try again" });
expect(page.queryByText(GONE)).toBeNull();
expect(page.getByRole("alert").textContent).toContain("Couldn't load this link.");
fireEvent.click(retry);
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
});
it("a dropped connection is a failure with a retry too", async () => {
window.history.replaceState({}, "", "/watch/tok-offline");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
const page = render(<WatchPage />);
await page.findByRole("button", { name: "Try again" });
expect(page.queryByText(GONE)).toBeNull();
});
it("a 404 says the link is gone", async () => {
window.history.replaceState({}, "", "/watch/tok-404");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(res(404)));
const page = render(<WatchPage />);
await page.findByText(GONE);
expect(page.queryByRole("button", { name: "Try again" })).toBeNull();
});
it("a 404 whose video is being made again says to come back, with a retry", async () => {
window.history.replaceState({}, "", "/watch/tok-preparing");
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(res(404, { detail: { code: 404, reason: "video_preparing" } }))
.mockResolvedValue(
res(200, { needs_password: false, file_url: "/f.mp4", title: "A clip" }),
),
);
const page = render(<WatchPage />);
const retry = await page.findByRole("button", { name: "Try again" });
expect(page.getByRole("alert").textContent).toContain("This video is being prepared.");
expect(page.queryByText(GONE)).toBeNull();
fireEvent.click(retry);
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
});
it("a Retry that is answered 'still preparing' says so and keeps the focus on Retry", async () => {
// The answer replaces the button the viewer was on: announced (an alert, inserted filled), and
// the new Retry takes the focus instead of leaving it on <body>.
window.history.replaceState({}, "", "/watch/tok-preparing");
const preparing = () => res(404, { detail: { code: 404, reason: "video_preparing" } });
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(() => Promise.resolve(preparing())),
);
const page = render(<WatchPage />);
const first = await page.findByRole("button", { name: "Try again" });
expect(document.activeElement).not.toBe(first); // the first load moves no focus
fireEvent.click(first);
await waitFor(() =>
expect(document.activeElement).toBe(page.getByRole("button", { name: "Try again" })),
);
expect(page.getByRole("alert").textContent).toContain("This video is being prepared.");
});
async function tryPassword(second: () => Promise<unknown>) {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(res(200, { needs_password: true }))
.mockImplementationOnce(second),
);
const page = render(<WatchPage />);
fireEvent.change(await page.findByPlaceholderText("Enter password"), {
target: { value: "guess" },
});
fireEvent.click(page.getByRole("button", { name: "Watch" }));
return page;
}
it("too many password tries says so, and keeps the form", async () => {
window.history.replaceState({}, "", "/watch/tok-429");
const page = await tryPassword(async () => res(429));
await page.findByText("Too many attempts. Try again in a few minutes.");
const alert = page.getByRole("alert");
expect(alert.textContent).toBe("Too many attempts. Try again in a few minutes.");
expect(page.getByPlaceholderText("Enter password").getAttribute("aria-describedby")).toBe(
alert.id,
);
});
it("an unlock that gets no answer says so instead of doing nothing", async () => {
window.history.replaceState({}, "", "/watch/tok-unlock-offline");
const page = await tryPassword(() => Promise.reject(new TypeError("Failed to fetch")));
expect((await page.findByRole("alert")).textContent).toBe(
"Couldn't check the password. Try again.",
);
});
});
// A shared link opens where THIS browser stopped in it (the viewer has no account), and keeps the
// point as it plays — per link, so another link starts at its own place.
describe("WatchPage resume", () => {
function loaded(video: HTMLVideoElement, duration = 600) {
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;
}
it("keeps where it stopped, and opens there on the next visit", async () => {
window.history.replaceState({}, "", "/watch/tok-resume");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const first = render(<WatchPage />);
await waitFor(() => expect(first.container.querySelector("video")).not.toBeNull());
const video = loaded(first.container.querySelector("video")!);
expect(video.currentTime).toBe(0);
video.dispatchEvent(new Event("playing"));
video.currentTime = 250;
video.dispatchEvent(new Event("pause"));
cleanup();
const second = render(<WatchPage />);
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
expect(loaded(second.container.querySelector("video")!).currentTime).toBe(250);
cleanup();
window.history.replaceState({}, "", "/watch/tok-other");
const other = render(<WatchPage />);
await waitFor(() => expect(other.container.querySelector("video")).not.toBeNull());
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
});
});