Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
df6ddae
Merge remote-tracking branch 'origin/main' into feat/infra-player-cap…
richiemcilroy Nov 13, 2026
8dd8666
feat(infra): serve a video's captions through the playback route and …
richiemcilroy Nov 13, 2026
c57f7a4
feat(infra): captions button and cues in the hosted player
richiemcilroy Nov 13, 2026
f4c514f
feat(infra): captions=1 embed parameter turns captions on from the start
richiemcilroy Nov 13, 2026
0bb608b
feat(infra): show a video's captions on its console page and in its p…
richiemcilroy Nov 13, 2026
e452b97
fix(infra): keep caption tracks loading in Chrome alongside hls.js
richiemcilroy Nov 13, 2026
cc73c30
Merge remote-tracking branch 'origin/perf/infra-player-safari-start' …
richiemcilroy Nov 13, 2026
8728f38
fix(infra): show captions that finish while the console video page is…
richiemcilroy Nov 13, 2026
d48972b
Merge remote-tracking branch 'origin/main' into feat/infra-player-cap…
richiemcilroy Nov 13, 2026
918fbc0
fix(infra): turn captions off when the language that was on goes away
richiemcilroy Nov 13, 2026
4009e70
Merge remote-tracking branch 'origin/perf/infra-player-safari-start' …
richiemcilroy Nov 13, 2026
870764e
fix(infra): lift captions clear of the taller touch controls
richiemcilroy Nov 13, 2026
19a9280
Merge remote-tracking branch 'origin/perf/infra-player-safari-start' …
richiemcilroy Nov 13, 2026
26e9f75
fix(infra): let the time label give way before the controls on narrow…
richiemcilroy Nov 13, 2026
7d382b7
Merge remote-tracking branch 'origin/perf/infra-player-safari-start' …
richiemcilroy Nov 13, 2026
64eac88
Merge remote-tracking branch 'origin/main' into feat/infra-player-cap…
richiemcilroy Nov 13, 2026
e545eee
Merge remote-tracking branch 'origin/perf/infra-player-safari-start' …
richiemcilroy Nov 13, 2026
278abd4
Merge commit 'e75a126453ee6d2f7103919dbac647191da21d09' into feat/inf…
richiemcilroy Nov 13, 2026
73f51f2
Merge remote-tracking branch 'origin/main' into feat/infra-player-cap…
richiemcilroy Nov 13, 2026
29a184c
Merge remote-tracking branch 'origin/main' into feat/infra-player-cap…
richiemcilroy Nov 13, 2026
760946c
chore(infra): drop a comment that restates isSharper
richiemcilroy Nov 13, 2026
7ebc9a4
Merge remote-tracking branch 'origin/main' into feat/infra-player-cap…
richiemcilroy Nov 13, 2026
69a1290
Merge remote-tracking branch 'origin/main' into feat/infra-player-cap…
richiemcilroy Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 301 additions & 0 deletions apps/web/__tests__/unit/cap-player-captions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
// @vitest-environment jsdom

import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
activeCueText,
type CapPlayerCaption,
initialCaptions,
loadCaptionsFile,
toggledCaptions,
} from "@/components/cap-player/captions";

vi.mock("@/components/cap-player/source", async (original) => ({
...(await original<typeof import("@/components/cap-player/source")>()),
attachSource: () => ({
stop: () => undefined,
warm: () => undefined,
sharpen: () => undefined,
setLevel: () => undefined,
}),
}));

const { CapPlayer } = await import("@/components/cap-player/CapPlayer");

const english: CapPlayerCaption = {
language: "en",
label: "English",
url: "https://playback.example/p/captions-en.vtt",
};
const german: CapPlayerCaption = {
language: "de",
label: "German",
url: "https://playback.example/p/captions-de.vtt",
};
const VTT = "WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nHello there\n";

describe("caption state", () => {
it("starts off unless asked, and on the first language when asked", () => {
expect(initialCaptions([english, german], false)).toBeNull();
expect(initialCaptions([english, german], true)).toBe("en");
expect(initialCaptions([], true)).toBeNull();
});

it("turns off when on, and back on in the language picked last", () => {
expect(toggledCaptions("en", "en", [english, german])).toBeNull();
expect(toggledCaptions(null, "de", [english, german])).toBe("de");
expect(toggledCaptions(null, null, [english, german])).toBe("en");
expect(toggledCaptions(null, "fr", [english, german])).toBe("en");
expect(toggledCaptions(null, null, [])).toBeNull();
});

it("reads the text of every active cue as lines", () => {
const cues = [
{ getCueAsHTML: () => ({ textContent: " Hello &" }) },
{ text: "there" },
{ text: "" },
] as unknown as TextTrackCueList;

expect(activeCueText(cues)).toBe("Hello &\nthere");
expect(activeCueText(null)).toBe("");
});
});

describe("loading a caption file", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(VTT, { status: 200 })),
);
URL.createObjectURL = vi.fn(() => "blob:captions");
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("loads each file once and hands it over as a blob URL", async () => {
const cache = new Map<string, Promise<string>>();

expect(await loadCaptionsFile(cache, english.url)).toBe("blob:captions");
expect(await loadCaptionsFile(cache, english.url)).toBe("blob:captions");
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(english.url, { credentials: "omit" });
});

it("refuses a missing file or one that is not WebVTT, and tries again later", async () => {
const cache = new Map<string, Promise<string>>();
vi.mocked(fetch).mockResolvedValueOnce(
new Response("Not found", { status: 404 }),
);
await expect(loadCaptionsFile(cache, english.url)).rejects.toThrow("404");
expect(cache.size).toBe(0);

vi.mocked(fetch).mockResolvedValueOnce(new Response("<html>"));
await expect(loadCaptionsFile(cache, english.url)).rejects.toThrow(
"not WebVTT",
);

expect(await loadCaptionsFile(cache, english.url)).toBe("blob:captions");
});
});

describe("the captions button", () => {
let container: HTMLDivElement;
let root: Root;

const mount = async (props: {
captions?: CapPlayerCaption[];
showCaptions?: boolean;
controls?: boolean;
}) => {
await act(async () => {
root.render(
createElement(CapPlayer, {
source: { mp4: "https://playback.example/p/video.mp4" },
title: "Launch demo",
...props,
}),
);
});
};
const button = () =>
container.querySelector<HTMLButtonElement>(
'button[aria-label^="Captions"]',
);
const player = () => container.firstElementChild as HTMLElement;
const press = async (key: string) => {
const event = new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
});
await act(async () => {
player().dispatchEvent(event);
});
return event;
};
const click = async (element: Element | null) => {
await act(async () => {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
};

beforeEach(() => {
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(VTT, { status: 200 })),
);
URL.createObjectURL = vi.fn(() => "blob:captions");
URL.revokeObjectURL = vi.fn();
Element.prototype.scrollIntoView = () => undefined;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});

it("is not there for a video without captions, and c does nothing", async () => {
await mount({});

expect(button()).toBeNull();
expect((await press("c")).defaultPrevented).toBe(false);
expect(fetch).not.toHaveBeenCalled();
});

it("starts off and loads nothing until it is pressed", async () => {
await mount({ captions: [english] });

expect(button()?.getAttribute("aria-pressed")).toBe("false");
expect(fetch).not.toHaveBeenCalled();
expect(container.querySelectorAll("track[src]")).toHaveLength(0);

await click(button());

expect(button()?.getAttribute("aria-pressed")).toBe("true");
expect(fetch).toHaveBeenCalledWith(english.url, { credentials: "omit" });
const track = container.querySelector<HTMLTrackElement>("track[src]");
expect(track?.getAttribute("src")).toBe("blob:captions");
expect(track?.kind).toBe("captions");
expect(track?.srclang).toBe("en");
expect(track?.label).toBe("English");

await click(button());

expect(button()?.getAttribute("aria-pressed")).toBe("false");
});

it("keeps captions on through a refresh that hands over the same list", async () => {
await mount({ captions: [english], showCaptions: true });
const track = container.querySelector("track[src]");

await mount({ captions: [{ ...english }], showCaptions: true });

expect(button()?.getAttribute("aria-pressed")).toBe("true");
expect(fetch).toHaveBeenCalledTimes(1);
expect(container.querySelector("track[src]")).toBe(track);
});

it("offers captions that arrive after the player mounted", async () => {
await mount({});
expect(button()).toBeNull();

await mount({ captions: [english] });
expect(button()?.getAttribute("aria-pressed")).toBe("false");

await mount({ captions: [english, german] });
expect(button()?.getAttribute("aria-label")).toBe("Captions, off");
});

it("turns captions off when a refresh removes the language that was on", async () => {
await mount({ captions: [english, german] });
await click(button());
await click(
[...container.querySelectorAll('[role="menuitemradio"]')].find(
(item) => item.textContent === "English",
) ?? null,
);
expect(button()?.getAttribute("aria-label")).toBe("Captions, English");

await mount({ captions: [german] });

expect(button()?.getAttribute("aria-pressed")).toBe("false");
await press("c");
expect(button()?.getAttribute("aria-pressed")).toBe("true");
});

it("toggles with the c key", async () => {
await mount({ captions: [english] });

const on = await press("c");
expect(on.defaultPrevented).toBe(true);
expect(button()?.getAttribute("aria-pressed")).toBe("true");

await press("C");
expect(button()?.getAttribute("aria-pressed")).toBe("false");
});

it("starts on when asked", async () => {
await mount({ captions: [english], showCaptions: true });

expect(button()?.getAttribute("aria-pressed")).toBe("true");
expect(fetch).toHaveBeenCalledTimes(1);
});

it("keeps the c key working when the control bar is hidden", async () => {
await mount({ captions: [english], controls: false });

await press("c");

expect(fetch).toHaveBeenCalledWith(english.url, { credentials: "omit" });
});

it("goes away when the file cannot be loaded", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response("Not found", { status: 404 }),
);
await mount({ captions: [english], showCaptions: true });

expect(button()).toBeNull();
expect((await press("c")).defaultPrevented).toBe(false);
});

it("offers a language menu when there is more than one", async () => {
await mount({ captions: [english, german] });

expect(button()?.getAttribute("aria-label")).toBe("Captions, off");
expect(button()?.getAttribute("aria-haspopup")).toBe("menu");

await click(button());
const items = [...container.querySelectorAll('[role="menuitemradio"]')].map(
(item) => [item.textContent, item.getAttribute("aria-checked")],
);
expect(items).toEqual([
["Off", "true"],
["English", "false"],
["German", "false"],
]);

await click(
[...container.querySelectorAll('[role="menuitemradio"]')].find(
(item) => item.textContent === "German",
) ?? null,
);
expect(button()?.getAttribute("aria-label")).toBe("Captions, German");
expect(fetch).toHaveBeenCalledWith(german.url, { credentials: "omit" });

await press("c");
expect(button()?.getAttribute("aria-label")).toBe("Captions, off");
await press("c");
expect(button()?.getAttribute("aria-label")).toBe("Captions, German");
});
});
8 changes: 5 additions & 3 deletions apps/web/__tests__/unit/developer-transcript-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,14 @@ describe("transcribing a stored video", () => {
expect(transcriptDocs).toContain("`videoId`, `contentLength`, `name`");
});

it("says only what ships: no player captions and no captions URL", () => {
it("says only what ships: captions in the hosted player and no captions URL", () => {
for (const text of [transcriptDocs, specText]) {
expect(text).not.toMatch(/captions for the hosted player/i);
expect(text).not.toMatch(/\.vtt|captions\//i);
}
expect(transcriptDocs).not.toMatch(/player/i);
expect(transcriptDocs).toContain(
"The hosted player and the embed then show a captions button, and the `c` key turns captions on and off. Add `captions=1` to `embedUrl` to start with them on.",
);
expect(specText).toContain("controls=0 and captions=1");
});
});

Expand Down
Loading
Loading