Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ How most packages work, so the bullets below don't repeat it:
- A video's `width` and `height` are as written, both or neither, or read with comark-zoom's `measure()` unless the `size` option is `false`. Every renderer writes them on the `<video>`, and the 16:9 box its styles reserve applies only without them.
- The renderers wrap the `<video controls>` or `<audio controls>` in `.cm-video` or `.cm-audio` with a bar of controls, one string from `controls()` in `src/client.ts` that every renderer inlines.
- The script in `start()` marks each player `data-media-ready` to show the bar and drops a video's own controls. Without the script, and when the file can't play, the browser's controls show. The script is built like carousel's.
- The settings panel's Copy link takes the file's address from the fallback `<a>` inside the media element, so a host that plays it from a `blob:` URL still copies the real one. Copy link and Copy frame first send a cancelable `comark-media-copy` event, so a host can copy another way.
- `packages/metrics` is `comark-metrics`.
- Its parse plugin works out each `:delta`'s arrow, percent, and verdict, and renames `:progress` to `progress-bar` with its percent, so a raw `<progress>` never reaches a component.
- It replaces `::stats` with its table plus a Change column of `delta` cells, or with `tiles` with a `stat-tiles` node of `div` tiles holding a `dt` name, a `dd` value, and a `delta`.
Expand Down
65 changes: 62 additions & 3 deletions e2e/media.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,22 @@ const serve = (body: Buffer, type: string) => (route: Route) => {
const open = async (
page: Page,
markdown: string,
options: { script?: boolean; typeset?: boolean; width?: string } = {},
options: { script?: boolean; typeset?: boolean; width?: string; origin?: string } = {},
) => {
const origin = options.origin ?? "http://media.test";
await page.route("**/silence.wav*", serve(silence, "audio/wav"));
await page.route("**/clip.webm", serve(clip, "video/webm"));
await page.route("**/missing.wav", (route) => route.fulfill({ status: 404 }));
let html = await render(markdown);
if (options.script === false) html = html.replace(/<script>[\s\S]*?<\/script>/g, "");
if (options.typeset) html = `<main><style>${typesetCss}</style>${html}</main>`;
await page.route("http://media.test/", (route) =>
await page.route(`${origin}/`, (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><html><body style="width:${options.width ?? "32rem"};margin:2rem">${html}</body></html>`,
}),
);
await page.goto("http://media.test/", { waitUntil: "domcontentloaded" });
await page.goto(`${origin}/`, { waitUntil: "domcontentloaded" });
};

test("an audio player plays, pauses, and seeks with its own controls", async ({ page }) => {
Expand Down Expand Up @@ -533,3 +534,61 @@ test("the browser's player shows without the script, and when the file can't pla
await expect(missing.locator("audio")).toBeVisible();
await expect(missing.locator(".cm-bar")).toBeHidden();
});

test("Copy link gives the file's address at the second the player is at, and Copy frame the frame as a PNG", async ({
page,
context,
}) => {
// localhost is a secure context, so the clipboard is there to grant.
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await open(page, "![A gray clip](clip.webm)\n\n![Silence](silence.wav)", {
origin: "http://localhost:4790",
});
const player = page.getByRole("group", { name: "A gray clip" });
await expect(player.locator(".cm-time").last()).toHaveText("0:04");
await player.getByRole("button", { name: "Settings" }).click();
await expect(player.getByRole("button", { name: "Copy link" })).toBeVisible();
// Each action keeps its icon beside its label.
for (const action of [".cm-copy-link", ".cm-copy-frame"])
expect((await player.locator(`${action} svg`).boundingBox())?.width).toBeGreaterThan(10);
await expect(page.getByRole("group", { name: "Silence" }).locator(".cm-copy-frame")).toHaveCount(
0,
);

await player.locator("video").evaluate((video: HTMLVideoElement) => (video.currentTime = 2.6));
const link = player.getByRole("button", { name: "Copy link at 0:02" });
await link.click();
await expect(player.getByRole("status")).toHaveText("Copied");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
"http://localhost:4790/clip.webm#t=2",
);

// A host that copies by other means takes the copy by cancelling the event.
const taken = page.evaluate(
() =>
new Promise<{ kind: string; type: string; size: number; time: number }>((resolve) =>
document.addEventListener(
"comark-media-copy",
(event) => {
event.preventDefault();
const { detail } = event;
if (detail.kind === "frame")
resolve({
kind: detail.kind,
type: detail.image.type,
size: detail.image.size,
time: detail.time,
});
},
{ once: true },
),
),
);
await player.getByRole("button", { name: "Copy frame" }).click();
const frame = await taken;
expect(frame).toMatchObject({ kind: "frame", type: "image/png" });
expect(frame.size).toBeGreaterThan(0);
expect(frame.time).toBeCloseTo(2.6, 1);
await expect(player.getByRole("status")).toHaveText("Copied");
await expect(player).not.toHaveAttribute("data-playing");
});
2 changes: 1 addition & 1 deletion packages/media/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ https://github.com/user-attachments/assets/2f3c0e3e-1c4b-4c3a-9a6b-0b1e2f3a4b5c

An image ending in `.mp4`, `.webm`, `.mov`, or `.m4v` becomes `<video controls playsinline preload="metadata">`, and one ending in `.mp3`, `.wav`, `.ogg`, or `.m4a` becomes `<audio controls preload="metadata">`. The alt text labels the player. `autoplay`, `loop`, `muted`, `poster`, `width`, and `height` pass through. Give a video paired pixel dimensions like `{width=1280 height=720}` to reserve its room. `media({ size: true })` measures remote videos without dimensions while parsing. Enable it only for trusted documents: requests follow redirects and can reach private networks. By default measurement reads only data URIs; `media({ size: false })` disables it.

Players come with a play button, the time, a seek bar, and a mute button with a volume slider over it, and a settings panel for the speed and looping, drawn with the shared `--comark-*` tokens; a video's float over its bottom edge and add a full screen button, and on a touch screen spread over the video with the sound and settings in its top corner. A small script runs them. Until it does, and when a file can't play, the browser's own controls show instead, so the file plays without any script. Pass `media({ controls: "native" })` to keep the browser's controls and leave the script out.
Players come with a play button, the time, a seek bar, and a mute button with a volume slider over it, and a settings panel for the speed and looping, with Copy link (at the current second, as `#t=`) and a video's Copy frame, drawn with the shared `--comark-*` tokens; a video's float over its bottom edge and add a full screen button, and on a touch screen spread over the video with the sound and settings in its top corner. A small script runs them. Until it does, and when a file can't play, the browser's own controls show instead, so the file plays without any script. Pass `media({ controls: "native" })` to keep the browser's controls and leave the script out.

comark-zoom never zooms a video and comark-chips never turns an attachment link into a chip, in any plugin order.

Expand Down
19 changes: 19 additions & 0 deletions packages/media/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The alert sound the reviewer asked for:
- Only the first frame and the length load until the reader presses play.
- The controls match the page. The browser's own show before the script runs and when the file can't play.
- A settings panel sets the speed, from 0.5× to 2×, and turns looping on or off.
- The settings panel copies a link to the file at the second the player is at, as a `#t=` media fragment, and a video's frame on screen as a PNG. See [Copying](#copying).
- On a touch screen, a video's controls spread over the picture, with play in the middle.
- Starting a player with sound pauses the others. Muted ones play on.

Expand Down Expand Up @@ -97,6 +98,24 @@ https://github.com/user-attachments/assets/2f3c0e3e-1c4b-4c3a-9a6b-0b1e2f3a4b5c

A link with text of its own, or one inside a sentence, stays a link.


## Copying

**Copy link** gives the file's address with `#t=` and the second the player is at, so the link opens the file there. The address comes from the fallback link inside the `<video>` or `<audio>`, so it stays the file's own when a host plays it from a `blob:` URL.

**Copy frame** pauses a video and copies the frame on screen as a PNG. A file from another host needs CORS headers for that, or the panel says it can't.

Both write to the clipboard. A host that can't, or wants to copy another way, listens for `comark-media-copy` on the player or above it and cancels the event:

```ts
import type { MediaCopyDetail } from "comark-media/client";

document.addEventListener("comark-media-copy", (event: CustomEvent<MediaCopyDetail>) => {
event.preventDefault();
if (event.detail.kind === "link") share(event.detail.text);
else upload(event.detail.image);
});
```
## Examples

### Recordings as evidence
Expand Down
121 changes: 120 additions & 1 deletion packages/media/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
/**
* What the `comark-media-copy` event carries: the link to the file at the time the player is at (`#t=` seconds, none
* at the start), or the frame on screen as a PNG.
*/
export type MediaCopyDetail =
| { kind: "link"; text: string; time: number }
| { kind: "frame"; image: Blob; time: number };

declare global {
interface Window {
__comarkMedia?: { users: number; stop: () => void; sync: () => void };
}
// The event bubbles from the player, so a listener anywhere above it gets a typed `detail`.
interface HTMLElementEventMap {
"comark-media-copy": CustomEvent<MediaCopyDetail>;
}
interface DocumentEventMap {
"comark-media-copy": CustomEvent<MediaCopyDetail>;
}
interface WindowEventMap {
"comark-media-copy": CustomEvent<MediaCopyDetail>;
}
// Safari's, on an iPhone, which has no full screen for any other element.
interface HTMLVideoElement {
webkitEnterFullscreen?: () => void;
Expand All @@ -25,9 +43,14 @@ const option = (name: string, label: string, paths: string, choices: string[][])
.join("") +
"</span>";

const linkIcon =
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>';
const frameIcon =
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/>';

/**
* A player's controls, the same in every renderer: play, the time, volume, and a settings panel for the speed and
* looping; a video's add a full screen button. They show once the script runs;
* looping, with Copy link (at the time it's at) and a video's Copy frame; a video's add a full screen button. They show once the script runs;
* until then, and when the file can't play, the browser's own controls show instead.
*/
export const controls = (kind: "audio" | "video") =>
Expand All @@ -52,6 +75,11 @@ export const controls = (kind: "audio" | "video") =>
["on", "On"],
],
) +
`<span class="cm-actions"><button type="button" class="cm-copy-link">${icon("link", linkIcon, false)}<span>Copy link</span></button>` +
(kind === "video"
? `<button type="button" class="cm-copy-frame">${icon("frame", frameIcon, false)}<span>Copy frame</span></button>`
: "") +
'<span class="cm-status" role="status"></span></span>' +
"</span></span>" +
(kind === "video"
? `<button type="button" class="cm-full" aria-label="Full screen">${icon("full", '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>', false)}${icon("exit", '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>', false)}</button>`
Expand Down Expand Up @@ -163,6 +191,10 @@ export function start(css: string): () => void {
? Number(input.value) === media.playbackRate
: (input.value === "on") === media.loop;
}
const link = player.querySelector(".cm-copy-link>span");
const second = Math.floor(at);
if (link && !player.hasAttribute("data-copied"))
link.textContent = second ? `Copy link at ${clock(second)}` : "Copy link";
const seek = player.querySelector<HTMLInputElement>(".cm-seek");
if (!seek) return;
seek.max = String(total);
Expand Down Expand Up @@ -306,6 +338,93 @@ export function start(css: string): () => void {
{ signal },
);

// The file's own address is the fallback link's: a host may play the file from a blob: URL of its own.
const linkAt = (media: HTMLMediaElement, second: number) => {
const href = media.querySelector<HTMLAnchorElement>(":scope>a[href]")?.href || media.currentSrc;
const url = href.replace(/#t=[^&]*$/, "").replace(/#$/, "");
return second ? `${url}${url.includes("#") ? "&" : "#"}t=${second}` : url;
};
const frameOf = (video: HTMLVideoElement) =>
new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
if (!context || !canvas.width) return reject(new Error("No frame yet"));
context.drawImage(video, 0, 0);
// A file from another host without CORS taints the canvas, and reading it throws.
try {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("No frame"))),
"image/png",
);
} catch (error) {
reject(error);
}
});
const said = new WeakMap<Element, number>();
function say(player: HTMLElement, button: Element, text: string) {
const status = player.querySelector(".cm-status");
if (status) status.textContent = text;
const label = button.querySelector("span");
const before = label?.textContent ?? "";
player.setAttribute("data-copied", "");
if (label) label.textContent = text;
clearTimeout(said.get(player));
said.set(
player,
window.setTimeout(() => {
player.removeAttribute("data-copied");
if (status) status.textContent = "";
if (label && button.classList.contains("cm-copy-frame")) label.textContent = before;
draw(player);
}, 1500),
);
}
async function copy(player: HTMLElement, button: Element, media: HTMLMediaElement) {
const time = Math.floor(media.currentTime);
let detail: MediaCopyDetail;
if (button.classList.contains("cm-copy-link"))
detail = { kind: "link", text: linkAt(media, time), time };
else if (media instanceof HTMLVideoElement) {
media.pause();
try {
detail = { kind: "frame", image: await frameOf(media), time: media.currentTime };
} catch {
return say(player, button, "Can't copy this video's frames");
}
} else return;
// A host that copies by other means, such as a page in a sandboxed frame, takes the copy by cancelling the event.
const event = new CustomEvent("comark-media-copy", { bubbles: true, cancelable: true, detail });
if (!player.dispatchEvent(event)) return say(player, button, "Copied");
// SAFETY: `clipboard` is missing outside secure contexts, whatever the DOM types say.
const clipboard = navigator.clipboard as Clipboard | undefined;
const written =
detail.kind === "link"
? clipboard?.writeText(detail.text)
: "ClipboardItem" in window
? clipboard?.write([new ClipboardItem({ "image/png": detail.image })])
: undefined;
if (!written) return say(player, button, "Couldn't copy");
written.then(
() => say(player, button, "Copied"),
() => say(player, button, "Couldn't copy"),
);
}
document.addEventListener(
"click",
(event) => {
const button =
event.target instanceof Element
? event.target.closest(".cm-copy-link,.cm-copy-frame")
: null;
const player = playerOf(button);
const media = player && mediaOf(player);
if (button && player && media) void copy(player, button, media);
},
{ signal },
);

document.addEventListener(
"fullscreenchange",
() => {
Expand Down
7 changes: 7 additions & 0 deletions packages/media/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ export const css = `@layer comark{${[
".cm-choices input{position:absolute;inset:0;margin:0;opacity:0;cursor:inherit}",
".cm-choices label:has(:checked){background:var(--_comark-background);color:var(--_comark-foreground);box-shadow:0 1px 2px rgb(0 0 0/.08)}",
"@media (hover:hover){.cm-choices label:hover{color:var(--_comark-foreground)}}",
// Copy link and Copy frame are a row of their own under the settings, plain like a menu's items.
".cm-actions{grid-column:1/-1;display:flex;flex-direction:column;gap:2px;margin:.125rem -.25rem 0;padding-top:.375rem;border-top:1px solid var(--_comark-border)}",
".cm-actions button{display:flex;align-items:center;gap:.5rem;height:1.75rem;padding:0 .5rem;border:0;border-radius:calc(var(--_comark-radius) - 3px);background:none;color:var(--_comark-foreground);font:inherit;font-weight:500;font-variant-numeric:tabular-nums;white-space:nowrap;text-align:start;cursor:pointer}",
".cm-actions svg{flex:none;color:var(--_comark-muted)}",
"@media (hover:hover){.cm-actions button:hover{background:var(--_comark-accent)}}",
`.cm-actions button:focus-visible{outline:none;${ring}}`,
".cm-status{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}",
`.cm-choices label:has(:focus-visible){${ring}}`,
// Fit the panel to the bar, leaving room for the full screen button beside its desktop anchor. Choices can wrap
// in a smaller player without shrinking their text; the touch anchor sits at the bar's right edge instead.
Expand Down
2 changes: 2 additions & 0 deletions packages/media/test/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ describe("comark-media", () => {
expect(html).toContain(
'<span class="cm-choices" role="radiogroup" aria-label="Loop"><label><input type="radio" data-option="loop" value="off">Off</label><label><input type="radio" data-option="loop" value="on">On</label></span>',
);
expect(html.match(/class="cm-copy-link"/g)).toHaveLength(3);
expect(html.match(/class="cm-copy-frame"/g)).toHaveLength(1);
expect(html.match(/<script>/g)).toHaveLength(1);
});

Expand Down
Loading