From d45f731f3e998300b9102baf506b16a3723bf2fe Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:20:38 -0500 Subject: [PATCH 01/20] feat(infra): master playlist can list one rendition or cap the height ?rendition=720p serves a master that lists only that rendition, with its audio, so Safari's own HLS player can be switched to it. ?max=720 keeps renditions no taller than 720. Malformed or unknown values leave the master as it is. --- .../unit/developer-video-playback.test.ts | 66 +++++++++++++++ apps/web/lib/developer-video-playback.ts | 82 ++++++++++++++++++- 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/developer-video-playback.test.ts b/apps/web/__tests__/unit/developer-video-playback.test.ts index c167730be7..71af51ef6e 100644 --- a/apps/web/__tests__/unit/developer-video-playback.test.ts +++ b/apps/web/__tests__/unit/developer-video-playback.test.ts @@ -541,6 +541,72 @@ describe("developer video playback route", () => { } }); + describe("choosing renditions", () => { + const stored = ladder( + variant("1080p", 4_573_338, "1920x1080"), + variant("720p", 2_501_452, "1280x720"), + variant("360p", 739_748, "640x360"), + ); + const served = async (query: string) => + (await play("master.m3u8", PLAYBACK_ID, asking(query))).text(); + + beforeEach(() => { + mocks.readText.mockResolvedValue(stored); + }); + + it("serves a master that lists only the picked rendition, with its audio", async () => { + const body = await served("?rendition=1080p"); + + expect(order(body)).toEqual(["1080p.m3u8"]); + expect(body).toContain( + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="audio",URI="audio.m3u8"', + ); + expect(body).toContain("RESOLUTION=1920x1080"); + }); + + it("keeps renditions no taller than the cap", async () => { + expect(order(await served("?max=720"))).toEqual([ + "720p.m3u8", + "360p.m3u8", + ]); + expect(order(await served("?max=1080"))).toEqual([ + "720p.m3u8", + "1080p.m3u8", + "360p.m3u8", + ]); + }); + + it("keeps the smallest rendition when the cap is below every one", async () => { + expect(order(await served("?max=240"))).toEqual(["360p.m3u8"]); + }); + + it("carries a cap and a pick into the short form", async () => { + expect( + (await served("?max=720&segments=short")) + .split("\n") + .filter((line) => line.endsWith(".m3u8?segments=short")), + ).toEqual(["720p.m3u8?segments=short", "360p.m3u8?segments=short"]); + expect(order(await served("?rendition=360p&max=720"))).toEqual([ + "360p.m3u8", + ]); + }); + + it.each([ + "?rendition=480p", + "?rendition=audio", + "?rendition=../1080p", + "?rendition=1080p.m3u8", + "?rendition=1080p&rendition=720p", + "?max=720p", + "?max=-1", + "?max=abc", + "?max=720&max=360", + "?rendition=%0A%23EXT-X-ENDLIST", + ])("leaves the master as it serves it for %s", async (query) => { + expect(await served(query)).toBe(await served("")); + }); + }); + it("redirects a segment listed by file name to the URL the playlist signs", async () => { mocks.readText.mockResolvedValue(storedRendition(16)); diff --git a/apps/web/lib/developer-video-playback.ts b/apps/web/lib/developer-video-playback.ts index 0486c64006..0c8f42b7a0 100644 --- a/apps/web/lib/developer-video-playback.ts +++ b/apps/web/lib/developer-video-playback.ts @@ -196,6 +196,84 @@ export async function signMediaPlaylist( return lines.join("\n"); } +const RENDITION_NAME_PATTERN = /^(\d{3,4})p$/; +const RENDITION_HEIGHT_PATTERN = /^\d{3,4}$/; + +function onlyQueryValue(request: Request, name: string): string | null { + const values = new URL(request.url).searchParams.getAll(name); + return values.length === 1 ? (values[0] ?? null) : null; +} + +export function renditionChoice(request: Request): { + rendition: string | null; + max: number | null; +} { + const rendition = onlyQueryValue(request, "rendition"); + const max = onlyQueryValue(request, "max"); + return { + rendition: + rendition && RENDITION_NAME_PATTERN.test(rendition) ? rendition : null, + max: max && RENDITION_HEIGHT_PATTERN.test(max) ? Number(max) : null, + }; +} + +// A master playlist narrowed to some of its renditions. Safari's own HLS +// player offers no way to pick a rendition, so the hosted player picks one by +// loading a master that lists only it; the audio rendition stays listed, which +// a rendition's own playlist would leave out. A cap keeps every rendition no +// taller than it, or the smallest when none is. A choice that matches nothing +// leaves the master as it is. +export function selectRenditions( + master: string, + choice: { rendition: string | null; max: number | null }, +): string { + if (choice.rendition === null && choice.max === null) return master; + const lines = master.split(/\r?\n/).filter((line) => line.trim().length > 0); + const others: string[] = []; + const variants: { height: number; uri: string; lines: string[] }[] = []; + for (let index = 0; index < lines.length; index++) { + const line = lines[index] ?? ""; + if (!line.startsWith("#EXT-X-STREAM-INF:")) { + others.push(line); + continue; + } + const uri = lines[index + 1]?.trim(); + if (!uri || uri.startsWith("#")) return master; + const named = /^(\d{3,4})p\.m3u8$/.exec(uri); + const resolution = line.match(/[:,]RESOLUTION=(\d+)x(\d+)/); + const height = named + ? Number(named[1]) + : resolution + ? Math.min(Number(resolution[1]), Number(resolution[2])) + : 0; + variants.push({ height, uri, lines: [line, uri] }); + index++; + } + if (variants.length === 0) return master; + + let kept = variants; + if (choice.rendition !== null) { + const named = variants.filter( + (variant) => variant.uri === `${choice.rendition}.m3u8`, + ); + if (named.length > 0) kept = named; + } + if (choice.max !== null) { + const max = choice.max; + const below = kept.filter((variant) => variant.height <= max); + kept = + below.length > 0 + ? below + : [ + kept.reduce((low, variant) => + variant.height < low.height ? variant : low, + ), + ]; + } + if (kept.length === variants.length) return master; + return `${[...others, ...kept.flatMap((variant) => variant.lines)].join("\n")}\n`; +} + // Players start on the first rendition a master playlist lists, and the encoder // lists the largest first. Lead with the best rendition an ordinary connection // starts quickly and leave the others in their order, so the player steps up or @@ -357,7 +435,9 @@ export async function developerPlaybackResponse( if (!playlist) return notFound(); const short = wantsShortSegments(request); if (file === DEVELOPER_VIDEO_MASTER_PLAYLIST) { - const master = leadWithStartRendition(playlist); + const master = leadWithStartRendition( + selectRenditions(playlist, renditionChoice(request)), + ); return playlistResponse(short ? listShortSegmentPlaylists(master) : master); } return playlistResponse( From 3b7fca2100ee4444dc492888318c3670f41af418 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:20:38 -0500 Subject: [PATCH 02/20] feat(infra): quality menu on native HLS in Safari on iPhone and iPad The player reads the renditions from the master playlist once metadata is in, and a pick loads the single-rendition master, keeping the time and play state. Auto goes back to the full master. --- .../__tests__/unit/cap-player-source.test.ts | 183 +++++++++++++++++- apps/web/components/cap-player/source.ts | 89 ++++++++- 2 files changed, 270 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/cap-player-source.test.ts b/apps/web/__tests__/unit/cap-player-source.test.ts index ec0a094e39..cc5bb764ac 100644 --- a/apps/web/__tests__/unit/cap-player-source.test.ts +++ b/apps/web/__tests__/unit/cap-player-source.test.ts @@ -1,9 +1,13 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + AUTO_LEVEL, attachSource, + listLevels, + parseMasterLevels, pickPlaybackMode, pickStartingLevel, playAllowingMuted, + renditionSource, SECONDS_AHEAD_BEFORE_PLAY, SECONDS_TO_SHARPEN, startWithin, @@ -121,6 +125,13 @@ const browser = (options: { }); }; +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("offline"))), + ); +}); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -1128,3 +1139,173 @@ describe("attachSource sharpening while the pointer rests on play", () => { expect(video.preload).toBe("metadata"); }); }); + +const MASTER = [ + "#EXTM3U", + "#EXT-X-VERSION:7", + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="group_aud",NAME="audio",DEFAULT=YES,URI="audio.m3u8"', + '#EXT-X-STREAM-INF:BANDWIDTH=2501452,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2",AUDIO="group_aud"', + "720p.m3u8", + '#EXT-X-STREAM-INF:BANDWIDTH=4573338,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2",AUDIO="group_aud"', + "1080p.m3u8", + '#EXT-X-STREAM-INF:BANDWIDTH=739748,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2",AUDIO="group_aud"', + "360p.m3u8", + "", +].join("\n"); + +describe("native HLS renditions", () => { + it("reads Cap's renditions from a master playlist", () => { + expect(parseMasterLevels(MASTER)).toEqual([ + { width: 1280, height: 720, bitrate: 2501452, uri: "720p.m3u8" }, + { width: 1920, height: 1080, bitrate: 4573338, uri: "1080p.m3u8" }, + { width: 640, height: 360, bitrate: 739748, uri: "360p.m3u8" }, + ]); + expect( + listLevels(parseMasterLevels(MASTER)).map((level) => level.label), + ).toEqual(["1080p", "720p", "360p"]); + }); + + it("skips variants that are not Cap renditions", () => { + const odd = [ + "#EXTM3U", + "#EXT-X-STREAM-INF:BANDWIDTH=1,RESOLUTION=1280x720", + "https://elsewhere.example/720p.m3u8", + "#EXT-X-STREAM-INF:BANDWIDTH=1,RESOLUTION=1280x720", + "../720p.m3u8", + "#EXT-X-STREAM-INF:BANDWIDTH=1,RESOLUTION=1280x720", + "#EXT-X-ENDLIST", + ].join("\n"); + + expect(parseMasterLevels(odd)).toEqual([]); + expect(parseMasterLevels("")).toEqual([]); + }); + + it("points a picked rendition at a master that lists only it", () => { + iphone(true); + expect(renditionSource(HLS, "720p.m3u8")).toBe(`${HLS}?rendition=720p`); + expect(renditionSource(`${HLS}?max=1080`, "720p.m3u8")).toBe( + `${HLS}?max=1080&rendition=720p`, + ); + expect(renditionSource(HLS, "720p.m3u8?segments=short")).toBe( + `${HLS}?rendition=720p`, + ); + expect(renditionSource(HLS, "audio.m3u8")).toBeNull(); + expect(renditionSource(HLS, undefined)).toBeNull(); + }); + + const nativePlayer = async () => { + iphone(true); + const fetchMaster = vi.fn(async (_url: string) => new Response(MASTER)); + vi.stubGlobal("fetch", fetchMaster); + const video = new FakeVideo(true); + const attached = attach(video, { hls: HLS, mp4: MP4 }); + expect(fetchMaster).not.toHaveBeenCalled(); + video.reach(30); + await vi.waitFor(() => expect(attached.onLevels).toHaveBeenCalledTimes(2)); + const levels = attached.onLevels.mock.calls[1]?.[0] as ReturnType< + typeof listLevels + >; + const index = (label: string) => { + const level = levels.find((entry) => entry.label === label); + if (!level) throw new Error(`no ${label}`); + return level.index; + }; + return { video, fetchMaster, index, ...attached }; + }; + + it("lists the renditions once the metadata is in, from the same master", async () => { + const { fetchMaster, onLevels } = await nativePlayer(); + + expect(fetchMaster).toHaveBeenCalledTimes(1); + expect(fetchMaster.mock.calls[0]?.[0]).toBe(HLS); + expect( + (onLevels.mock.calls[1]?.[0] as { label: string }[]).map( + (level) => level.label, + ), + ).toEqual(["1080p", "720p", "360p"]); + }); + + it("switches to a picked rendition and keeps the time and play state", async () => { + const { video, setLevel, index } = await nativePlayer(); + await video.play(); + video.currentTime = 12.5; + + setLevel(index("720p")); + + expect(video.src).toBe(`${HLS}?rendition=720p`); + expect(video.plays).toBe(2); + video.currentTime = 0; + video.reach(30); + expect(video.currentTime).toBe(12.5); + }); + + it("keeps a paused video paused when it switches", async () => { + const { video, setLevel, index } = await nativePlayer(); + video.currentTime = 4; + + setLevel(index("360p")); + + expect(video.src).toBe(`${HLS}?rendition=360p`); + expect(video.plays).toBe(0); + }); + + it("goes back to the full master for Auto, and does nothing for the same pick", async () => { + const { video, setLevel, index } = await nativePlayer(); + + setLevel(AUTO_LEVEL); + expect(video.src).toBe(HLS); + + setLevel(index("1080p")); + expect(video.src).toBe(`${HLS}?rendition=1080p`); + video.src = "changed-elsewhere"; + setLevel(index("1080p")); + expect(video.src).toBe("changed-elsewhere"); + + setLevel(AUTO_LEVEL); + expect(video.src).toBe(HLS); + }); + + it("ignores a pick it has no rendition for", async () => { + const { video, setLevel } = await nativePlayer(); + + setLevel(42); + + expect(video.src).toBe(HLS); + }); + + it("offers no menu when the master cannot be read", async () => { + iphone(true); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("nope", { status: 404 })), + ); + const video = new FakeVideo(true); + const { onLevels } = attach(video, { hls: HLS }); + video.reach(30); + await vi.waitFor(() => expect(onLevels).toHaveBeenCalledTimes(2)); + + expect(onLevels.mock.calls[1]?.[0]).toEqual([]); + }); + + it("stops listing once detached", async () => { + iphone(true); + let release: (response: Response) => void = () => undefined; + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ), + ); + const video = new FakeVideo(true); + const { onLevels, stop } = attach(video, { hls: HLS }); + video.reach(30); + stop(); + release(new Response(MASTER)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onLevels).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/cap-player/source.ts b/apps/web/components/cap-player/source.ts index bf78824aa5..6f715317a2 100644 --- a/apps/web/components/cap-player/source.ts +++ b/apps/web/components/cap-player/source.ts @@ -82,6 +82,48 @@ export const listLevels = ( ); }; +const RENDITION_URI = /^(\d{3,4}p)\.m3u8(?:$|\?)/; + +export interface MasterLevel { + width: number; + height: number; + bitrate: number; + uri: string; +} + +export const parseMasterLevels = (master: string): MasterLevel[] => { + const lines = master.split(/\r?\n/).map((line) => line.trim()); + const levels: MasterLevel[] = []; + lines.forEach((line, index) => { + if (!line.startsWith("#EXT-X-STREAM-INF:")) return; + const uri = lines[index + 1]; + if (!uri || !RENDITION_URI.test(uri)) return; + const resolution = /[:,]RESOLUTION=(\d+)x(\d+)/.exec(line); + levels.push({ + width: Number(resolution?.[1] ?? 0), + height: Number(resolution?.[2] ?? 0), + bitrate: Number(/[:,]BANDWIDTH=(\d+)/.exec(line)?.[1] ?? 0), + uri, + }); + }); + return levels; +}; + +// Safari's own HLS player picks renditions by itself and lets no page pick +// one. Picking means loading a master playlist that lists only that +// rendition, with its audio, so the time and play state carry over. +export const renditionSource = (hls: string, uri: string | undefined) => { + const name = RENDITION_URI.exec(uri ?? "")?.[1]; + if (!name) return null; + try { + const url = new URL(hls, document.baseURI); + url.searchParams.set("rendition", name); + return url.toString(); + } catch { + return null; + } +}; + const HLS_MIME = "application/vnd.apple.mpegurl"; // Safari and Firefox give no connection estimate. Assuming a slow line costs // a fast one its first segment at a lower rendition. Assuming a fast line @@ -255,11 +297,52 @@ export const attachSource = ( let fileAttached = false; let nativeAttached = false; + let nativeLevels: MasterLevel[] = []; + let nativeSrc = ""; + let listing: AbortController | null = null; function onNativeError() { playFile(); } + function listNativeLevels() { + video.removeEventListener("loadedmetadata", listNativeLevels); + const hls = source.hls; + if (!hls || listing || cancelled || fileAttached) return; + listing = new AbortController(); + fetch(hls, { signal: listing.signal }) + .then((response) => (response.ok ? response.text() : "")) + .then((master) => { + if (cancelled || fileAttached) return; + nativeLevels = parseMasterLevels(master); + onLevels?.(listLevels(nativeLevels)); + }) + .catch(() => undefined); + } + + function pickNativeLevel(index: number) { + const hls = source.hls; + if (!hls || fileAttached) return; + const next = + index === AUTO_LEVEL + ? hls + : renditionSource(hls, nativeLevels[index]?.uri); + if (!next || next === nativeSrc) return; + const { resumeAt, wasPlaying } = position(); + nativeSrc = next; + video.src = next; + if (resumeAt > 0) { + video.addEventListener( + "loadedmetadata", + () => { + video.currentTime = resumeAt; + }, + { once: true }, + ); + } + if (wasPlaying) void video.play().catch(() => undefined); + } + // When hls.js fails, the browser's own HLS is tried before the MP4. Only // on Apple browsers: others that claim HLS go straight to the file. function playNative({ resumeAt, wasPlaying } = position()) { @@ -270,11 +353,13 @@ export const attachSource = ( return; } nativeAttached = true; - setLevel = () => undefined; + setLevel = pickNativeLevel; onLevels?.([]); detach = () => release(video); video.addEventListener("error", onNativeError); + video.addEventListener("loadedmetadata", listNativeLevels); video.preload = "none"; + nativeSrc = source.hls; video.src = source.hls; if (resumeAt > 0) { startPending = false; @@ -312,7 +397,9 @@ export const attachSource = ( const stop = () => { cancelled = true; cancelWarm(); + listing?.abort(); video.removeEventListener("error", onNativeError); + video.removeEventListener("loadedmetadata", listNativeLevels); video.removeEventListener("loadedmetadata", seekToStart); detach(); }; From a8343ed783804750976b03b78737cf1957ab16bd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:20:38 -0500 Subject: [PATCH 03/20] feat(infra): postMessage events and commands for the embedded player Events ready, play, pause, ended, timeupdate (about four a second), qualitychange and error go to the embedding page. Commands play, pause, seek, mute, unmute and setQuality are taken only from that page's window and only in validated shapes. Adds keyboard and full screen switches. --- .../unit/cap-player-messages.test.ts | 291 ++++++++++++++++++ apps/web/components/cap-player/CapPlayer.tsx | 73 ++++- apps/web/components/cap-player/messages.ts | 153 +++++++++ 3 files changed, 513 insertions(+), 4 deletions(-) create mode 100644 apps/web/__tests__/unit/cap-player-messages.test.ts create mode 100644 apps/web/components/cap-player/messages.ts diff --git a/apps/web/__tests__/unit/cap-player-messages.test.ts b/apps/web/__tests__/unit/cap-player-messages.test.ts new file mode 100644 index 0000000000..7782dce82a --- /dev/null +++ b/apps/web/__tests__/unit/cap-player-messages.test.ts @@ -0,0 +1,291 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + connectMessages, + createThrottle, + type MessageTarget, + parseCommand, + qualityKey, + TIMEUPDATE_GAP_MS, +} from "@/components/cap-player/messages"; + +class FakeVideo extends EventTarget { + currentTime = 0; + duration = 30; + paused = true; + muted = false; + plays = 0; + refuse = false; + + play() { + this.plays++; + if (this.refuse) { + const error = new Error("refused"); + error.name = "NotAllowedError"; + return Promise.reject(error); + } + this.paused = false; + return Promise.resolve(); + } + + pause() { + this.paused = true; + } +} + +const embedded = () => { + const posted: { message: Record; origin: string }[] = []; + const parent = { + postMessage: (message: unknown, origin: string) => { + posted.push({ message: message as Record, origin }); + }, + }; + const view = new EventTarget(); + const target = Object.assign(view, { parent }) as unknown as MessageTarget; + const video = new FakeVideo(); + const setQuality = vi.fn(); + const connection = connectMessages( + target, + video as unknown as HTMLVideoElement, + setQuality, + ); + const command = (data: unknown, source: unknown = parent) => + view.dispatchEvent( + Object.assign(new Event("message"), { data, source }) as MessageEvent, + ); + const events = () => posted.map(({ message }) => message.event); + return { posted, video, setQuality, connection, command, events, parent }; +}; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("parseCommand", () => { + it.each([ + [{ type: "cap-player", command: "play" }, { command: "play" }], + [{ type: "cap-player", command: "pause" }, { command: "pause" }], + [{ type: "cap-player", command: "mute" }, { command: "mute" }], + [{ type: "cap-player", command: "unmute" }, { command: "unmute" }], + [ + { type: "cap-player", command: "seek", time: 12.5 }, + { command: "seek", time: 12.5 }, + ], + [ + { type: "cap-player", command: "seek", time: 0 }, + { command: "seek", time: 0 }, + ], + [ + { type: "cap-player", command: "setQuality", quality: "720p" }, + { command: "setQuality", quality: "720p" }, + ], + [ + { type: "cap-player", command: "setQuality", quality: 1080 }, + { command: "setQuality", quality: "1080" }, + ], + [ + { type: "cap-player", command: "setQuality", quality: "Auto" }, + { command: "setQuality", quality: "auto" }, + ], + [ + { type: "cap-player", command: "setQuality", quality: "4K" }, + { command: "setQuality", quality: "4k" }, + ], + ])("accepts %j", (data, parsed) => { + expect(parseCommand(data)).toEqual(parsed); + }); + + it.each([ + null, + undefined, + "play", + 42, + [], + ["cap-player", "play"], + { command: "play" }, + { type: "other", command: "play" }, + { type: "cap-player" }, + { type: "cap-player", command: "PLAY" }, + { type: "cap-player", command: "destroy" }, + { type: "cap-player", command: "__proto__" }, + { type: "cap-player", command: "toString" }, + { type: "cap-player", command: { toString: (): string => "play" } }, + { type: "cap-player", command: "seek" }, + { type: "cap-player", command: "seek", time: "12" }, + { type: "cap-player", command: "seek", time: -1 }, + { type: "cap-player", command: "seek", time: Number.NaN }, + { type: "cap-player", command: "seek", time: Number.POSITIVE_INFINITY }, + { type: "cap-player", command: "seek", time: 1e12 }, + { type: "cap-player", command: "setQuality" }, + { type: "cap-player", command: "setQuality", quality: "" }, + { type: "cap-player", command: "setQuality", quality: "best" }, + { type: "cap-player", command: "setQuality", quality: "720p;x" }, + { + type: "cap-player", + command: "setQuality", + quality: "", + }, + { type: "cap-player", command: "setQuality", quality: { label: "720p" } }, + { type: "cap-player", command: "setQuality", quality: 1.5 }, + ])("refuses %j", (data) => { + expect(parseCommand(data)).toBeNull(); + }); +}); + +describe("qualityKey", () => { + it("treats the ways of naming one quality as the same", () => { + expect(qualityKey("1080p")).toBe(qualityKey("1080")); + expect(qualityKey("4K")).toBe(qualityKey("2160p")); + expect(qualityKey("4k")).toBe(qualityKey("2160")); + expect(qualityKey("720p")).not.toBe(qualityKey("1080p")); + }); +}); + +describe("createThrottle", () => { + it("lets at most one event through in each gap", () => { + const pass = createThrottle(TIMEUPDATE_GAP_MS); + const passed = [0, 50, 100, 239, 240, 300, 480, 481, 1000].filter(pass); + + expect(passed).toEqual([0, 240, 480, 1000]); + }); + + it("allows about four a second for events every frame", () => { + const pass = createThrottle(TIMEUPDATE_GAP_MS); + const frames = Array.from({ length: 60 }, (_, index) => index * 16.7); + + expect(frames.filter(pass)).toEqual([0, 250.5, 501, 751.5]); + }); +}); + +describe("connectMessages", () => { + it("says it is ready, to any origin, with the playback state only", () => { + const { posted } = embedded(); + + expect(posted).toEqual([ + { + message: { + type: "cap-player", + event: "ready", + currentTime: 0, + duration: 30, + paused: true, + muted: false, + }, + origin: "*", + }, + ]); + }); + + it("passes on play, pause and ended", () => { + const { video, events } = embedded(); + + video.dispatchEvent(new Event("play")); + video.dispatchEvent(new Event("pause")); + video.dispatchEvent(new Event("ended")); + + expect(events()).toEqual(["ready", "play", "pause", "ended"]); + }); + + it("passes on time updates at most about four times a second", () => { + let now = 0; + vi.stubGlobal("performance", { now: () => now }); + const { video, posted } = embedded(); + + for (let frame = 0; frame < 60; frame++) { + now = frame * 16.7; + video.currentTime = now / 1000; + video.dispatchEvent(new Event("timeupdate")); + } + + const updates = posted.filter( + ({ message }) => message.event === "timeupdate", + ); + expect(updates).toHaveLength(4); + expect(updates[1]?.message.currentTime).toBe(0.251); + }); + + it("reports a duration that is not known yet as zero", () => { + const { video, connection, posted } = embedded(); + video.duration = Number.NaN; + + connection.send("qualitychange", { quality: "720p", auto: true }); + + expect(posted.at(-1)?.message).toMatchObject({ + event: "qualitychange", + duration: 0, + quality: "720p", + auto: true, + }); + }); + + it("follows commands from the embedding page", async () => { + const { video, command, setQuality } = embedded(); + + command({ type: "cap-player", command: "play" }); + await Promise.resolve(); + expect(video.paused).toBe(false); + + command({ type: "cap-player", command: "pause" }); + expect(video.paused).toBe(true); + + command({ type: "cap-player", command: "mute" }); + expect(video.muted).toBe(true); + command({ type: "cap-player", command: "unmute" }); + expect(video.muted).toBe(false); + + command({ type: "cap-player", command: "seek", time: 12 }); + expect(video.currentTime).toBe(12); + command({ type: "cap-player", command: "seek", time: 999 }); + expect(video.currentTime).toBe(30); + + command({ type: "cap-player", command: "setQuality", quality: "720p" }); + expect(setQuality).toHaveBeenCalledWith("720p"); + }); + + it("ignores messages from any window but the embedding page", async () => { + const { video, command } = embedded(); + + command({ type: "cap-player", command: "play" }, null); + command({ type: "cap-player", command: "play" }, {}); + await Promise.resolve(); + + expect(video.plays).toBe(0); + }); + + it("ignores malformed commands", () => { + const { video, command, setQuality, posted } = embedded(); + + command({ type: "cap-player", command: "seek", time: "1e3" }); + command({ type: "cap-player", command: "setQuality", quality: "x" }); + command("play"); + + expect(video.currentTime).toBe(0); + expect(setQuality).not.toHaveBeenCalled(); + expect(posted).toHaveLength(1); + }); + + it("reports a play the browser refuses", async () => { + const { video, command, posted } = embedded(); + video.refuse = true; + + command({ type: "cap-player", command: "play" }); + await vi.waitFor(() => expect(posted).toHaveLength(2)); + + expect(posted[1]?.message).toMatchObject({ + event: "error", + code: "play_refused", + }); + }); + + it("stops listening and passing events on once stopped", async () => { + const { video, command, connection, posted } = embedded(); + + connection.stop(); + video.dispatchEvent(new Event("play")); + command({ type: "cap-player", command: "play" }); + await Promise.resolve(); + + expect(posted).toHaveLength(1); + expect(video.plays).toBe(0); + }); +}); diff --git a/apps/web/components/cap-player/CapPlayer.tsx b/apps/web/components/cap-player/CapPlayer.tsx index c45eb187c2..ef2422f259 100644 --- a/apps/web/components/cap-player/CapPlayer.tsx +++ b/apps/web/components/cap-player/CapPlayer.tsx @@ -18,6 +18,7 @@ import { sameCaptionsKey, toggledCaptions, } from "./captions"; +import { connectMessages, qualityKey } from "./messages"; import styles from "./player.module.css"; import { type Attachment, @@ -55,6 +56,8 @@ export interface CapPlayerProps { focusOnMount?: boolean; accentColor?: string; hideControls?: readonly CapPlayerControl[]; + keyboard?: boolean; + events?: boolean; speeds?: readonly number[]; className?: string; style?: CSSProperties; @@ -289,6 +292,8 @@ export const CapPlayer = ({ focusOnMount = false, accentColor, hideControls = NO_CONTROLS, + keyboard = true, + events = false, speeds = DEFAULT_SPEEDS, className, style, @@ -343,8 +348,16 @@ export const CapPlayer = ({ return true; }; - const live = useRef({ onFirstPlay, onEnded, speeds, toggleCaptions }); - live.current = { onFirstPlay, onEnded, speeds, toggleCaptions }; + const live = useRef({ + onFirstPlay, + onEnded, + speeds, + toggleCaptions, + keyboard, + fullscreen: !hideControls.includes("fullscreen"), + setQuality: (_quality: string): void => undefined, + }); + const messages = useRef | null>(null); const [playing, setPlaying] = useState(false); const [started, setStarted] = useState(autoPlay); @@ -656,6 +669,7 @@ export const CapPlayer = ({ void document.exitFullscreen(); return; } + if (!live.current.fullscreen) return; if (root.requestFullscreen) { void root.requestFullscreen().catch(() => undefined); return; @@ -800,6 +814,7 @@ export const CapPlayer = ({ }; const onKey = (event: KeyboardEvent) => { + if (!live.current.keyboard) return; if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target instanceof Element ? event.target : null; if (target?.closest('[role="menu"]')) return; @@ -1003,16 +1018,29 @@ export const CapPlayer = ({ closeMenu("speed"); }; - const pickQuality = (value: number) => { + const applyQuality = (value: number) => { attachmentRef.current?.setLevel(value); setPicked(value); - closeMenu("quality"); onQualityChange?.({ auto: value === AUTO_LEVEL, label: levels.find((level) => level.index === value)?.label ?? resolution, }); }; + const pickQuality = (value: number) => { + applyQuality(value); + closeMenu("quality"); + }; + + const setQualityByName = (name: string) => { + const value = + name === "auto" + ? AUTO_LEVEL + : levels.find((level) => qualityKey(level.label) === qualityKey(name)) + ?.index; + if (value !== undefined && value !== picked) applyQuality(value); + }; + const qualityItems: MenuItem[] = [ { value: AUTO_LEVEL, @@ -1055,6 +1083,43 @@ export const CapPlayer = ({ const canPickQuality = levels.length > 1; const pickedLabel = levels.find((level) => level.index === picked)?.label; const quality = pickedLabel ?? resolution; + const autoQuality = picked === AUTO_LEVEL; + + live.current = { + onFirstPlay, + onEnded, + speeds, + toggleCaptions, + keyboard, + fullscreen: shows("fullscreen"), + setQuality: setQualityByName, + }; + + useEffect(() => { + const video = videoRef.current; + if (!events || !video || window.parent === window) return; + const connection = connectMessages(window, video, (name) => + live.current.setQuality(name), + ); + messages.current = connection; + return () => { + messages.current = null; + connection.stop(); + }; + }, [events]); + + useEffect(() => { + if (quality) { + messages.current?.send("qualitychange", { + quality, + auto: autoQuality, + }); + } + }, [quality, autoQuality]); + + useEffect(() => { + if (failed) messages.current?.send("error", { code: "media" }); + }, [failed]); return (
=> + typeof value === "object" && value !== null && !Array.isArray(value); + +export const parseCommand = (data: unknown): PlayerCommand | null => { + if (!isRecord(data) || data.type !== MESSAGE_TYPE) return null; + const { command } = data; + if (typeof command !== "string") return null; + if (SIMPLE_COMMANDS.has(command)) { + return { command } as PlayerCommand; + } + if (command === "seek") { + const { time } = data; + return typeof time === "number" && + Number.isFinite(time) && + time >= 0 && + time <= MAX_SEEK_SECONDS + ? { command, time } + : null; + } + if (command === "setQuality") { + const { quality } = data; + const text = + typeof quality === "number" ? String(quality) : (quality ?? ""); + return typeof text === "string" && QUALITY_PATTERN.test(text) + ? { command, quality: text.toLowerCase() } + : null; + } + return null; +}; + +// "1080", "1080p" and a level labelled "1080p" are the same quality, and so +// are "4k", "2160" and "2160p". +export const qualityKey = (label: string) => { + const key = label.toLowerCase().replace(/p$/, ""); + return key === "4k" ? "2160" : key; +}; + +export const createThrottle = (gapMs: number) => { + let last = Number.NEGATIVE_INFINITY; + return (now: number) => { + if (now - last < gapMs) return false; + last = now; + return true; + }; +}; + +const roundTime = (seconds: number) => + Number.isFinite(seconds) ? Math.round(seconds * 1000) / 1000 : 0; + +export const playerEvent = ( + event: PlayerEventName, + video: Pick< + HTMLVideoElement, + "currentTime" | "duration" | "paused" | "muted" + >, + extra?: Record, +) => ({ + type: MESSAGE_TYPE, + event, + currentTime: roundTime(video.currentTime), + duration: roundTime(video.duration), + paused: video.paused, + muted: video.muted, + ...extra, +}); + +export interface MessageTarget { + parent: { postMessage: (message: unknown, targetOrigin: string) => void }; + addEventListener: ( + type: "message", + listener: (event: MessageEvent) => void, + ) => void; + removeEventListener: ( + type: "message", + listener: (event: MessageEvent) => void, + ) => void; +} + +// Events go to any embedding page: they carry only the playback state, never +// the video's links. Commands are taken only from the embedding page's window, +// whatever its origin, and only in the shapes parseCommand allows. +export const connectMessages = ( + target: MessageTarget, + video: HTMLVideoElement, + setQuality: (quality: string) => void, +) => { + const send = ( + event: PlayerEventName, + extra?: Record, + ) => target.parent.postMessage(playerEvent(event, video, extra), "*"); + const throttle = createThrottle(TIMEUPDATE_GAP_MS); + + const onMessage = (message: MessageEvent) => { + if (message.source !== target.parent) return; + const parsed = parseCommand(message.data); + if (!parsed) return; + if (parsed.command === "play") { + void video.play().catch(() => send("error", { code: "play_refused" })); + } else if (parsed.command === "pause") video.pause(); + else if (parsed.command === "mute") video.muted = true; + else if (parsed.command === "unmute") video.muted = false; + else if (parsed.command === "seek") { + const end = Number.isFinite(video.duration) ? video.duration : 0; + video.currentTime = Math.min(end, parsed.time); + } else if (parsed.command === "setQuality") setQuality(parsed.quality); + }; + + const relayed: [string, () => void][] = [ + ["play", () => send("play")], + ["pause", () => send("pause")], + ["ended", () => send("ended")], + [ + "timeupdate", + () => { + if (throttle(performance.now())) send("timeupdate"); + }, + ], + ]; + for (const [name, handler] of relayed) video.addEventListener(name, handler); + target.addEventListener("message", onMessage); + send("ready"); + + return { + send, + stop: () => { + for (const [name, handler] of relayed) { + video.removeEventListener(name, handler); + } + target.removeEventListener("message", onMessage); + }, + }; +}; From f1b07cb4642769dd830b82a46b0c91241dd77dbd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:20:38 -0500 Subject: [PATCH 04/20] feat(infra): embed options for colour, radius, quality cap and hidden buttons color, radius, quality, quality_menu, speed_menu, pip, fullscreen, poster and keyboard are read strictly from the embed URL; anything malformed is ignored and never reaches the markup. --- .../unit/developer-video-page.test.ts | 53 ++++++- .../unit/developer-video-params.test.ts | 143 +++++++++++++++++- .../dev/_components/DeveloperVideoPage.tsx | 34 ++++- apps/web/lib/developer-video-params.ts | 65 ++++++++ 4 files changed, 286 insertions(+), 9 deletions(-) diff --git a/apps/web/__tests__/unit/developer-video-page.test.ts b/apps/web/__tests__/unit/developer-video-page.test.ts index 5a98669566..a70aa80291 100644 --- a/apps/web/__tests__/unit/developer-video-page.test.ts +++ b/apps/web/__tests__/unit/developer-video-page.test.ts @@ -381,8 +381,59 @@ describe("developer video page parameters", () => { ).toContain('aria-pressed="false"'); }); + it("colours the player with the embed's accent", () => { + expect(embed({ color: "3b82f6" })).toContain("--cap-player-accent:#3b82f6"); + expect(embed({})).not.toContain("--cap-player-accent"); + }); + + it.each([ + "red;}", + '#fff" onload="alert(1)', + "3b82f6;background:url(https://evil.example/x)", + ])("keeps the hostile colour %j out of the page", (color) => { + const html = embed({ color }); + + expect(html).toBe(embed({})); + expect(html).not.toContain("evil.example"); + expect(html).not.toContain("alert(1)"); + }); + + it("rounds the embed's corners on a clear page", () => { + const html = embed({ radius: "12" }); + + expect(html).toContain("border-radius:12px"); + expect(html).toContain("html,body{background:transparent}"); + expect(embed({})).toContain("html,body{background:#000}"); + expect(embed({})).not.toContain("border-radius:"); + expect(embed({ radius: "12px;}" })).toBe(embed({})); + }); + + it("leaves the poster out when asked", () => { + expect(embed({ poster: "0" })).not.toContain("poster="); + expect(embed({ poster: "1" })).toContain(`poster="${BASE}/poster.jpg"`); + }); + + it("hides the buttons the embed switches off", () => { + expect(embed({})).toContain('aria-label="Full screen"'); + const html = embed({ fullscreen: "0", speed_menu: "0" }); + + expect(html).not.toContain('aria-label="Full screen"'); + expect(html).not.toContain("Playback speed"); + expect(html).toContain('aria-label="Play"'); + }); + it("keeps the embed-only switches off the share page", () => { - const query = { autoplay: "1", muted: "1", loop: "1", controls: "0" }; + const query = { + autoplay: "1", + muted: "1", + loop: "1", + controls: "0", + color: "f00", + radius: "12", + poster: "0", + fullscreen: "0", + quality: "360", + }; expect( render(playing, "share", developerVideoPlayback(query, "share")), diff --git a/apps/web/__tests__/unit/developer-video-params.test.ts b/apps/web/__tests__/unit/developer-video-params.test.ts index 596b1111a7..e543973548 100644 --- a/apps/web/__tests__/unit/developer-video-params.test.ts +++ b/apps/web/__tests__/unit/developer-video-params.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { developerVideoPlayback } from "@/lib/developer-video-params"; +import { + cappedPlaylistUrl, + developerVideoPlayback, +} from "@/lib/developer-video-params"; const DEFAULTS = { autoPlay: false, @@ -8,6 +11,13 @@ const DEFAULTS = { controls: true, captions: false, startAt: 0, + accentColor: null, + radius: 0, + maxQuality: null, + poster: true, + keyboard: true, + events: true, + hideControls: [], }; describe("developerVideoPlayback", () => { @@ -29,6 +39,7 @@ describe("developerVideoPlayback", () => { "embed", ), ).toEqual({ + ...DEFAULTS, autoPlay: true, muted: true, loop: true, @@ -144,6 +155,134 @@ describe("developerVideoPlayback", () => { }, "share", ), - ).toEqual({ ...DEFAULTS, startAt: 42 }); + ).toEqual({ ...DEFAULTS, startAt: 42, events: false }); + }); + + it("reads every look and feel parameter", () => { + expect( + developerVideoPlayback( + { + color: "3B82F6", + radius: "12", + quality: "720", + poster: "0", + keyboard: "0", + quality_menu: "0", + speed_menu: "0", + pip: "0", + fullscreen: "0", + }, + "embed", + ), + ).toEqual({ + ...DEFAULTS, + accentColor: "#3b82f6", + radius: 12, + maxQuality: 720, + poster: false, + keyboard: false, + hideControls: ["quality", "speed", "pip", "fullscreen"], + }); + }); + + it.each([ + ["3b82f6", "#3b82f6"], + ["#3b82f6", "#3b82f6"], + ["F80", "#ff8800"], + ["#abc", "#aabbcc"], + ])("takes the accent colour %j", (color, accentColor) => { + expect(developerVideoPlayback({ color }, "embed").accentColor).toBe( + accentColor, + ); + }); + + it.each([ + "red", + "#12345", + "1234567", + "#3b82f6;background:url(x)", + "3b82f6", + "var(--x)", + "#3b82f6 ", + "%233b82f6", + "", + "#", + "rgb(0,0,0)", + "expression(alert(1))", + ])("ignores the hostile or malformed colour %j", (color) => { + expect(developerVideoPlayback({ color }, "embed").accentColor).toBeNull(); + }); + + it.each([ + ["0", 0], + ["8", 8], + ["24", 24], + ["99", 24], + ["-4", 0], + ["12px", 0], + ["1.5", 0], + ["100", 0], + ["", 0], + ["12;}", 0], + ])("reads the corner radius %j as %d px", (radius, expected) => { + expect(developerVideoPlayback({ radius }, "embed").radius).toBe(expected); + }); + + it.each([ + ["720", 720], + ["720p", 720], + ["1080P", 1080], + ["2160", 2160], + ["360", 360], + ["99", null], + ["9999", null], + ["720px", null], + ["auto", null], + ["-720", null], + ["720&max=1", null], + ["", null], + ])("reads the quality cap %j as %s", (quality, expected) => { + expect(developerVideoPlayback({ quality }, "embed").maxQuality).toBe( + expected, + ); + }); + + it("hides only the menus and buttons that are switched off", () => { + expect( + developerVideoPlayback( + { quality_menu: "0", speed_menu: "1", pip: "yes", fullscreen: "false" }, + "embed", + ).hideControls, + ).toEqual(["quality", "fullscreen"]); + }); + + it("keeps the look and feel parameters to the embed", () => { + expect( + developerVideoPlayback( + { color: "f00", radius: "8", quality: "720", poster: "0", pip: "0" }, + "share", + ), + ).toMatchObject({ + accentColor: null, + radius: 0, + maxQuality: null, + poster: true, + hideControls: [], + }); + }); +}); + +describe("cappedPlaylistUrl", () => { + const MASTER = "https://web.example/api/developer/playback/abc/master.m3u8"; + + it("leaves the playlist alone without a cap", () => { + expect(cappedPlaylistUrl(MASTER, null)).toBe(MASTER); + }); + + it("asks the playback route for renditions up to the cap", () => { + expect(cappedPlaylistUrl(MASTER, 720)).toBe(`${MASTER}?max=720`); + expect(cappedPlaylistUrl(`${MASTER}?max=1080`, 720)).toBe( + `${MASTER}?max=720`, + ); }); }); diff --git a/apps/web/app/dev/_components/DeveloperVideoPage.tsx b/apps/web/app/dev/_components/DeveloperVideoPage.tsx index 87aa44f812..8644106cf2 100644 --- a/apps/web/app/dev/_components/DeveloperVideoPage.tsx +++ b/apps/web/app/dev/_components/DeveloperVideoPage.tsx @@ -5,6 +5,7 @@ import { preload } from "react-dom"; import { CapPlayer } from "@/components/cap-player/CapPlayer"; import { Lockup } from "@/components/infra/Lockup"; import { + cappedPlaylistUrl, type DeveloperVideoPlayback, type DeveloperVideoQuery, developerVideoPlayback, @@ -89,13 +90,18 @@ function Stage({ playback: DeveloperVideoPlayback; }) { if (player.kind === "playing") { - if (player.poster) { - preload(player.poster, { as: "image", fetchPriority: "high" }); + const poster = playback.poster ? player.poster : null; + if (poster) { + preload(poster, { as: "image", fetchPriority: "high" }); } + const { hls, mp4 } = player.source; return ( ); } @@ -173,9 +183,21 @@ export function DeveloperVideoView({ const reportHref = developerVideoReportHref(playbackId); if (variant === "embed") { + const rounded = playback.radius > 0; return ( -
- +
+

{player.name}

; const SECONDS_PATTERN = /^\d{1,6}(\.\d{1,3})?$/; +const HEX_COLOR_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i; +const RADIUS_PATTERN = /^\d{1,2}$/; +const QUALITY_PATTERN = /^(\d{3,4})p?$/i; +export const MAX_EMBED_RADIUS = 24; +const LOWEST_QUALITY = 144; +const HIGHEST_QUALITY = 4320; + +const HIDDEN_BY: readonly [string, CapPlayerControl][] = [ + ["quality_menu", "quality"], + ["speed_menu", "speed"], + ["pip", "pip"], + ["fullscreen", "fullscreen"], +]; const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; @@ -26,6 +48,25 @@ const seconds = (value: string | string[] | undefined) => { return text && SECONDS_PATTERN.test(text) ? Number(text) : 0; }; +export const embedAccentColor = (value: string | string[] | undefined) => { + const hex = HEX_COLOR_PATTERN.exec(first(value) ?? "")?.[1]?.toLowerCase(); + if (!hex) return null; + return hex.length === 3 + ? `#${[...hex].map((digit) => digit + digit).join("")}` + : `#${hex}`; +}; + +export const embedRadius = (value: string | string[] | undefined) => { + const text = first(value); + if (!text || !RADIUS_PATTERN.test(text)) return 0; + return Math.min(MAX_EMBED_RADIUS, Number(text)); +}; + +export const embedMaxQuality = (value: string | string[] | undefined) => { + const height = Number(QUALITY_PATTERN.exec(first(value) ?? "")?.[1]); + return height >= LOWEST_QUALITY && height <= HIGHEST_QUALITY ? height : null; +}; + export function developerVideoPlayback( query: DeveloperVideoQuery, variant: "share" | "embed", @@ -38,5 +79,29 @@ export function developerVideoPlayback( controls: !embed || flag(query.controls, true), captions: embed && flag(query.captions, false), startAt: seconds(query.t), + accentColor: embed ? embedAccentColor(query.color) : null, + radius: embed ? embedRadius(query.radius) : 0, + maxQuality: embed ? embedMaxQuality(query.quality) : null, + poster: !embed || flag(query.poster, true), + keyboard: !embed || flag(query.keyboard, true), + events: embed, + hideControls: embed + ? HIDDEN_BY.filter(([name]) => !flag(query[name], true)).map( + ([, control]) => control, + ) + : [], }; } + +// Asks the playback route for a master playlist that lists no rendition +// taller than the cap, so hls.js and Safari's own player both keep under it. +export function cappedPlaylistUrl(hls: string, maxQuality: number | null) { + if (maxQuality === null) return hls; + try { + const url = new URL(hls); + url.searchParams.set("max", String(maxQuality)); + return url.toString(); + } catch { + return hls; + } +} From 570cbabd92ba107e574cb31f911b855a59832cf9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:20:38 -0500 Subject: [PATCH 05/20] feat(infra): embed code options on the console video page Accent colour, controls, autoplay muted, captions and the quality menu update the snippet as they change. --- .../__tests__/unit/infra-video-view.test.ts | 61 +++++++ .../videos/[videoId]/embed-options.tsx | 154 ++++++++++++++++++ .../[appId]/videos/[videoId]/video-client.tsx | 7 +- apps/web/lib/infra-video-view.ts | 42 ++++- 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(infra)/dashboard/infra/[appId]/videos/[videoId]/embed-options.tsx diff --git a/apps/web/__tests__/unit/infra-video-view.test.ts b/apps/web/__tests__/unit/infra-video-view.test.ts index c0a25b3e13..8d0ff87985 100644 --- a/apps/web/__tests__/unit/infra-video-view.test.ts +++ b/apps/web/__tests__/unit/infra-video-view.test.ts @@ -13,6 +13,7 @@ import { developerVideoPlaybackUrls, } from "@/lib/developer-video-api"; import { + DEFAULT_EMBED_OPTIONS, INFRA_VIDEO_DISPLAY_LABELS, INFRA_VIDEO_FAILURE_FALLBACK, INFRA_VIDEO_PAGE_MAX, @@ -20,6 +21,7 @@ import { infraRefreshDelayMs, infraVideoDisplayState, infraVideoEmbedCode, + infraVideoEmbedUrl, infraVideoFailure, infraVideoIsChanging, infraVideoListLimit, @@ -274,6 +276,65 @@ describe("console video payload", () => { ).toMatchObject({ shareUrl: null, embedCode: null }); }); + it("builds the snippet from the console's embed options", () => { + const video = { name: "Demo", width: 1920, height: 1080 }; + const base = { ...DEFAULT_EMBED_OPTIONS }; + + expect(infraVideoEmbedCode(`${PAGE}/embed`, video, base)).toBe( + infraVideoEmbedCode(`${PAGE}/embed`, video), + ); + expect(infraVideoEmbedUrl(`${PAGE}/embed`, base)).toBe(`${PAGE}/embed`); + expect( + infraVideoEmbedUrl(`${PAGE}/embed`, { + color: "#F97316", + controls: false, + autoplay: true, + captions: true, + qualityMenu: false, + }), + ).toBe( + `${PAGE}/embed?color=f97316&controls=0&autoplay=1&muted=1&captions=1&quality_menu=0`, + ); + expect( + infraVideoEmbedUrl(`${PAGE}/embed`, { ...base, captions: true }), + ).toBe(`${PAGE}/embed?captions=1`); + }); + + it("lets autoplay through the iframe only when the snippet autoplays", () => { + const video = { name: "Demo", width: 1920, height: 1080 }; + + expect( + infraVideoEmbedCode(`${PAGE}/embed`, video, { + ...DEFAULT_EMBED_OPTIONS, + autoplay: true, + }), + ).toContain('allow="autoplay; fullscreen; picture-in-picture"'); + expect(infraVideoEmbedCode(`${PAGE}/embed`, video)).toContain( + 'allow="fullscreen; picture-in-picture"', + ); + }); + + it.each([ + 'red" onload="alert(1)', + "#12345g", + "3b82f6&autoplay=1", + "", + ])("leaves the hostile colour %j out of the snippet", (color) => { + const code = infraVideoEmbedCode( + `${PAGE}/embed`, + { name: "Demo", width: 1920, height: 1080 }, + { ...DEFAULT_EMBED_OPTIONS, color }, + ); + + expect(code).toBe( + infraVideoEmbedCode(`${PAGE}/embed`, { + name: "Demo", + width: 1920, + height: 1080, + }), + ); + }); + it("keeps a video name from breaking out of the title attribute", () => { const code = infraVideoEmbedCode(`${PAGE}/embed`, { name: 'Demo "one" & more', diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/videos/[videoId]/embed-options.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/videos/[videoId]/embed-options.tsx new file mode 100644 index 0000000000..a7f1bb93d6 --- /dev/null +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/videos/[videoId]/embed-options.tsx @@ -0,0 +1,154 @@ +"use client"; + +import clsx from "clsx"; +import Link from "next/link"; +import { useId, useState } from "react"; +import { + DEFAULT_EMBED_OPTIONS, + type InfraEmbedOptions, + infraVideoEmbedCode, +} from "@/lib/infra-video-view"; +import { KeyRow } from "../../../_components/copy"; +import { FOCUS, MUTED, TEXT_LINK } from "../../../_components/ui"; + +const DEFAULT_ACCENT = "#3b82f6"; + +type Switch = Exclude; + +function Toggle({ + label, + on, + onChange, +}: { + label: string; + on: boolean; + onChange: (on: boolean) => void; +}) { + return ( + + ); +} + +export function EmbedOptions({ + embedUrl, + video, + hasCaptions, +}: { + embedUrl: string; + video: { name: string; width: number | null; height: number | null }; + hasCaptions: boolean; +}) { + const [options, setOptions] = useState(DEFAULT_EMBED_OPTIONS); + const colorId = useId(); + const set = (name: Switch) => (on: boolean) => + setOptions((current) => ({ ...current, [name]: on })); + + return ( + <> +
+ + {options.color && ( + + )} + + + {hasCaptions && ( + + )} + +
+ +

+ Corner radius, a quality cap, hiding other buttons and events your page + can listen to are in{" "} + + Embed Options + + . +

+ + ); +} diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/videos/[videoId]/video-client.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/videos/[videoId]/video-client.tsx index 3017df09dc..77c5cc535d 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/videos/[videoId]/video-client.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/videos/[videoId]/video-client.tsx @@ -33,6 +33,7 @@ import { } from "../../../_components/ui"; import { useLocalUpload } from "../../../_components/uploads"; import { VideoStatus } from "../../../_components/video-status"; +import { EmbedOptions } from "./embed-options"; const createdFormat = new Intl.DateTimeFormat("en-US", { month: "short", @@ -381,7 +382,11 @@ export function VideoClient({ description="A hosted player for this video. Send the link to a viewer, or paste the embed code into a page." > - + 0} + /> )} diff --git a/apps/web/lib/infra-video-view.ts b/apps/web/lib/infra-video-view.ts index 8b2f8f8308..c9d70a4439 100644 --- a/apps/web/lib/infra-video-view.ts +++ b/apps/web/lib/infra-video-view.ts @@ -1,3 +1,5 @@ +import { embedAccentColor } from "./developer-video-params"; + export type InfraVideoState = | "uploading" | "processing" @@ -186,9 +188,44 @@ const escapeAttribute = (value: string) => .replaceAll("<", "<") .replaceAll(">", ">"); +export interface InfraEmbedOptions { + color: string | null; + controls: boolean; + autoplay: boolean; + captions: boolean; + qualityMenu: boolean; +} + +export const DEFAULT_EMBED_OPTIONS: InfraEmbedOptions = { + color: null, + controls: true, + autoplay: false, + captions: false, + qualityMenu: true, +}; + +export function infraVideoEmbedUrl( + embedUrl: string, + options: InfraEmbedOptions = DEFAULT_EMBED_OPTIONS, +): string { + const query = new URLSearchParams(); + const color = embedAccentColor(options.color ?? undefined); + if (color) query.set("color", color.slice(1)); + if (!options.controls) query.set("controls", "0"); + if (options.autoplay) { + query.set("autoplay", "1"); + query.set("muted", "1"); + } + if (options.captions) query.set("captions", "1"); + if (!options.qualityMenu) query.set("quality_menu", "0"); + const search = query.toString(); + return search ? `${embedUrl}?${search}` : embedUrl; +} + export function infraVideoEmbedCode( embedUrl: string, video: { name: string; width: number | null; height: number | null }, + options: InfraEmbedOptions = DEFAULT_EMBED_OPTIONS, ): string { const measured = video.width !== null && @@ -196,7 +233,10 @@ export function infraVideoEmbedCode( video.width > 0 && video.height > 0; const shape = measured ? `${video.width} / ${video.height}` : "16 / 9"; - return ``; + const allow = options.autoplay + ? "autoplay; fullscreen; picture-in-picture" + : "fullscreen; picture-in-picture"; + return ``; } export function toInfraVideoDetail( From 27784a0254fcb97a74917365ccecd9ff8700056e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:20:38 -0500 Subject: [PATCH 06/20] docs(infra): embed options and player events --- apps/web/content/docs/api/rest-api.mdx | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 8ead80e1fd..b37e5aa7ff 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1510,6 +1510,77 @@ Add these to `embedUrl` as query parameters, for example `https://cap.so/dev/Zk3 A value the player does not understand is ignored, and so is a parameter it does not know. To let an embed autoplay with sound where the browser permits it, add `autoplay` to the iframe's `allow` list. +### Embed Options + +These change how the embedded player looks and what it offers. They work only on `embedUrl`. + +| Parameter | What it does | +|-----------|--------------| +| `color=3b82f6` | Accent colour of the progress bar, the volume level and the ticks in the menus. Six or three hex digits, with or without `#` (write `#` as `%23` in a URL) | +| `radius=12` | Rounds the player's corners by that many pixels, from `0` to `24`. A larger number is read as `24`. The page around the player is see-through, so the corners show your page | +| `quality=720` | Never plays a rendition taller than this, in Auto or from the menu. `720p` works too. A video with nothing that small plays its smallest rendition | +| `quality_menu=0` | Hides the quality menu. Auto keeps choosing the rendition | +| `speed_menu=0` | Hides the playback speed menu | +| `pip=0` | Hides the picture in picture button | +| `fullscreen=0` | Hides the full screen button and turns off full screen from a double click or the `f` key | +| `poster=0` | Shows no poster. The player stays black until it plays | +| `keyboard=0` | Turns off the player's keyboard shortcuts, such as space, `k`, the arrows, `m`, `f` and `c`. Tab and Enter still reach every control | + +The player has no title of its own, so there is nothing to hide. The quality menu offers Auto and every rendition in every browser, including Safari on iPhone and iPad, where picking one keeps the time and whether the video was playing. + +```html + +``` + +### Player Events + +The embedded player tells the page it sits on what happens, and takes commands from it, with `postMessage`. Every message is an object with `type: "cap-player"`. + +Events arrive as `{ type: "cap-player", event, currentTime, duration, paused, muted }`, with times in seconds. `duration` is `0` until it is known. Events carry no links and nothing about the viewer, and are sent to any origin. + +| Event | When | Extra fields | +|-------|------|--------------| +| `ready` | The player has loaded and takes commands | | +| `play` | Playback starts or resumes | | +| `pause` | Playback pauses | | +| `ended` | The video reaches its end | | +| `timeupdate` | The time moves while playing, at most about four times a second | | +| `qualitychange` | The rendition shown changes, or the viewer picks one | `quality` such as `"1080p"`, `auto` (true while Auto is on) | +| `error` | The video cannot be played, or the browser refused a `play` command | `code`: `"media"` or `"play_refused"` | + +Commands are sent to the iframe's `contentWindow` as `{ type: "cap-player", command }`. The player takes them only from the page that embeds it, and ignores anything else. + +| Command | Fields | What it does | +|---------|--------|--------------| +| `play` | | Plays. Browsers refuse to start a video with sound before the viewer has interacted with the page, so start it muted or after a click | +| `pause` | | Pauses | +| `seek` | `time`, seconds | Moves to that time. A time past the end moves to the end | +| `mute` | | Turns the sound off | +| `unmute` | | Turns the sound on | +| `setQuality` | `quality` | `"auto"`, or a rendition such as `"720p"`, `720` or `"4k"`. A rendition the video does not have is ignored | + +```html + + +``` + +Check `source` against the iframe's `contentWindow` as above, so a message from another frame on your page is not taken for the player's. + ### What the Player Loads Before the viewer presses play, the player loads the poster, the playlists and at most the first few seconds of video, so a page can hold several embeds without downloading them. It never holds back the load event of the page it sits on. `loading="lazy"` in the snippet goes further and leaves an embed below the fold unloaded until the viewer scrolls near it. An embed that is already in view loads at once. From ae50147a4f137b09e4ffd00ff2545f784b70185d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:05:39 -0500 Subject: [PATCH 07/20] docs(infra): keep the events example to the docs' wording --- apps/web/content/docs/api/rest-api.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index b37e5aa7ff..01228a2c1b 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1574,7 +1574,7 @@ Commands are sent to the iframe's `contentWindow` as `{ type: "cap-player", comm window.addEventListener("message", ({ source, data }) => { if (source !== player.contentWindow || data?.type !== "cap-player") return; if (data.event === "ready") send({ command: "seek", time: 30 }); - if (data.event === "ended") console.log("Watched to the end"); + if (data.event === "ended") console.log("Reached the end"); }); ``` From 66bc71558ca0912f5dfb1b707db2c967a5b8dfb8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:05:40 -0500 Subject: [PATCH 08/20] fix(infra): report only a play the browser refused, not one a pause interrupted --- .../web/__tests__/unit/cap-player-messages.test.ts | 14 ++++++++++++++ apps/web/components/cap-player/messages.ts | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/cap-player-messages.test.ts b/apps/web/__tests__/unit/cap-player-messages.test.ts index 7782dce82a..babdc484ba 100644 --- a/apps/web/__tests__/unit/cap-player-messages.test.ts +++ b/apps/web/__tests__/unit/cap-player-messages.test.ts @@ -277,6 +277,20 @@ describe("connectMessages", () => { }); }); + it("does not report a play that a pause interrupted", async () => { + const { video, command, posted } = embedded(); + video.play = () => { + const error = new Error("interrupted"); + error.name = "AbortError"; + return Promise.reject(error); + }; + + command({ type: "cap-player", command: "play" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(posted).toHaveLength(1); + }); + it("stops listening and passing events on once stopped", async () => { const { video, command, connection, posted } = embedded(); diff --git a/apps/web/components/cap-player/messages.ts b/apps/web/components/cap-player/messages.ts index 5a306f04a7..be23b14ae1 100644 --- a/apps/web/components/cap-player/messages.ts +++ b/apps/web/components/cap-player/messages.ts @@ -116,7 +116,11 @@ export const connectMessages = ( const parsed = parseCommand(message.data); if (!parsed) return; if (parsed.command === "play") { - void video.play().catch(() => send("error", { code: "play_refused" })); + void video.play().catch((error: unknown) => { + if (error instanceof Error && error.name === "NotAllowedError") { + send("error", { code: "play_refused" }); + } + }); } else if (parsed.command === "pause") video.pause(); else if (parsed.command === "mute") video.muted = true; else if (parsed.command === "unmute") video.muted = false; From e8befa630f7c3254c5affe90e3ec40cfb07566a2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:12:50 -0500 Subject: [PATCH 09/20] fix(infra): keep a seek or quality sent on ready until the player can apply it --- .../unit/cap-player-messages.test.ts | 28 +++++++++++++++++++ apps/web/components/cap-player/CapPlayer.tsx | 11 ++++++++ apps/web/components/cap-player/messages.ts | 23 ++++++++++++--- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/web/__tests__/unit/cap-player-messages.test.ts b/apps/web/__tests__/unit/cap-player-messages.test.ts index babdc484ba..0eab8c9a96 100644 --- a/apps/web/__tests__/unit/cap-player-messages.test.ts +++ b/apps/web/__tests__/unit/cap-player-messages.test.ts @@ -242,6 +242,34 @@ describe("connectMessages", () => { expect(setQuality).toHaveBeenCalledWith("720p"); }); + it("keeps a seek sent before the duration is known until the metadata arrives", () => { + const { video, command } = embedded(); + video.duration = Number.NaN; + + command({ type: "cap-player", command: "seek", time: 12 }); + expect(video.currentTime).toBe(0); + + video.duration = 30; + video.dispatchEvent(new Event("loadedmetadata")); + expect(video.currentTime).toBe(12); + + video.currentTime = 20; + video.dispatchEvent(new Event("durationchange")); + expect(video.currentTime).toBe(20); + }); + + it("keeps only the last early seek, and drops it once a seek lands", () => { + const { video, command } = embedded(); + video.duration = Number.NaN; + + command({ type: "cap-player", command: "seek", time: 12 }); + command({ type: "cap-player", command: "seek", time: 50 }); + video.duration = 30; + video.dispatchEvent(new Event("durationchange")); + + expect(video.currentTime).toBe(30); + }); + it("ignores messages from any window but the embedding page", async () => { const { video, command } = embedded(); diff --git a/apps/web/components/cap-player/CapPlayer.tsx b/apps/web/components/cap-player/CapPlayer.tsx index ef2422f259..22c98ddf36 100644 --- a/apps/web/components/cap-player/CapPlayer.tsx +++ b/apps/web/components/cap-player/CapPlayer.tsx @@ -358,6 +358,7 @@ export const CapPlayer = ({ setQuality: (_quality: string): void => undefined, }); const messages = useRef | null>(null); + const pendingQuality = useRef(null); const [playing, setPlaying] = useState(false); const [started, setStarted] = useState(autoPlay); @@ -1033,6 +1034,11 @@ export const CapPlayer = ({ }; const setQualityByName = (name: string) => { + if (levels.length === 0) { + pendingQuality.current = name; + return; + } + pendingQuality.current = null; const value = name === "auto" ? AUTO_LEVEL @@ -1108,6 +1114,11 @@ export const CapPlayer = ({ }; }, [events]); + useEffect(() => { + const pending = pendingQuality.current; + if (pending !== null && levels.length > 0) live.current.setQuality(pending); + }, [levels]); + useEffect(() => { if (quality) { messages.current?.send("qualitychange", { diff --git a/apps/web/components/cap-player/messages.ts b/apps/web/components/cap-player/messages.ts index be23b14ae1..43e1629ae7 100644 --- a/apps/web/components/cap-player/messages.ts +++ b/apps/web/components/cap-player/messages.ts @@ -110,6 +110,21 @@ export const connectMessages = ( extra?: Record, ) => target.parent.postMessage(playerEvent(event, video, extra), "*"); const throttle = createThrottle(TIMEUPDATE_GAP_MS); + // A parent may seek on ready, before the duration is known. The time is + // kept until the metadata arrives rather than clamped to nothing. + let pendingSeek: number | null = null; + const knownDuration = () => + Number.isFinite(video.duration) && video.duration > 0 + ? video.duration + : null; + const seek = (time: number) => { + const end = knownDuration(); + pendingSeek = end === null ? time : null; + if (end !== null) video.currentTime = Math.min(end, time); + }; + const seekWhenKnown = () => { + if (pendingSeek !== null && knownDuration() !== null) seek(pendingSeek); + }; const onMessage = (message: MessageEvent) => { if (message.source !== target.parent) return; @@ -124,13 +139,13 @@ export const connectMessages = ( } else if (parsed.command === "pause") video.pause(); else if (parsed.command === "mute") video.muted = true; else if (parsed.command === "unmute") video.muted = false; - else if (parsed.command === "seek") { - const end = Number.isFinite(video.duration) ? video.duration : 0; - video.currentTime = Math.min(end, parsed.time); - } else if (parsed.command === "setQuality") setQuality(parsed.quality); + else if (parsed.command === "seek") seek(parsed.time); + else if (parsed.command === "setQuality") setQuality(parsed.quality); }; const relayed: [string, () => void][] = [ + ["loadedmetadata", seekWhenKnown], + ["durationchange", seekWhenKnown], ["play", () => send("play")], ["pause", () => send("pause")], ["ended", () => send("ended")], From 67cefc5a8e4584c0833eaccd8f59dfc6ca568f5d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:12:50 -0500 Subject: [PATCH 10/20] fix(infra): drop a native quality switch's pending time when the source stops or changes --- .../__tests__/unit/cap-player-source.test.ts | 24 +++++++++++++++++++ apps/web/components/cap-player/source.ts | 24 +++++++++++++------ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/web/__tests__/unit/cap-player-source.test.ts b/apps/web/__tests__/unit/cap-player-source.test.ts index cc5bb764ac..e3690ecf2e 100644 --- a/apps/web/__tests__/unit/cap-player-source.test.ts +++ b/apps/web/__tests__/unit/cap-player-source.test.ts @@ -1265,6 +1265,30 @@ describe("native HLS renditions", () => { expect(video.src).toBe(HLS); }); + it("does not carry a switch's time over to a source attached after it stops", async () => { + const { video, setLevel, index, stop } = await nativePlayer(); + video.currentTime = 9; + + setLevel(index("360p")); + stop(); + video.currentTime = 0; + video.reach(30); + + expect(video.currentTime).toBe(0); + }); + + it("restores only the latest switch's time", async () => { + const { video, setLevel, index } = await nativePlayer(); + video.currentTime = 9; + setLevel(index("360p")); + video.currentTime = 4; + setLevel(index("720p")); + video.currentTime = 0; + video.reach(30); + + expect(video.currentTime).toBe(4); + }); + it("ignores a pick it has no rendition for", async () => { const { video, setLevel } = await nativePlayer(); diff --git a/apps/web/components/cap-player/source.ts b/apps/web/components/cap-player/source.ts index 6f715317a2..fb3b38952f 100644 --- a/apps/web/components/cap-player/source.ts +++ b/apps/web/components/cap-player/source.ts @@ -300,6 +300,7 @@ export const attachSource = ( let nativeLevels: MasterLevel[] = []; let nativeSrc = ""; let listing: AbortController | null = null; + let resumeAfterSwitch: (() => void) | null = null; function onNativeError() { playFile(); @@ -320,6 +321,13 @@ export const attachSource = ( .catch(() => undefined); } + function dropResume() { + if (resumeAfterSwitch) { + video.removeEventListener("loadedmetadata", resumeAfterSwitch); + } + resumeAfterSwitch = null; + } + function pickNativeLevel(index: number) { const hls = source.hls; if (!hls || fileAttached) return; @@ -329,16 +337,16 @@ export const attachSource = ( : renditionSource(hls, nativeLevels[index]?.uri); if (!next || next === nativeSrc) return; const { resumeAt, wasPlaying } = position(); + dropResume(); nativeSrc = next; video.src = next; if (resumeAt > 0) { - video.addEventListener( - "loadedmetadata", - () => { - video.currentTime = resumeAt; - }, - { once: true }, - ); + const resume = () => { + resumeAfterSwitch = null; + video.currentTime = resumeAt; + }; + resumeAfterSwitch = resume; + video.addEventListener("loadedmetadata", resume, { once: true }); } if (wasPlaying) void video.play().catch(() => undefined); } @@ -376,6 +384,7 @@ export const attachSource = ( return; } fileAttached = true; + dropResume(); setLevel = () => undefined; onLevels?.([]); video.removeEventListener("error", onNativeError); @@ -398,6 +407,7 @@ export const attachSource = ( cancelled = true; cancelWarm(); listing?.abort(); + dropResume(); video.removeEventListener("error", onNativeError); video.removeEventListener("loadedmetadata", listNativeLevels); video.removeEventListener("loadedmetadata", seekToStart); From a1eb734b41e8ba2de250d779f8b37221f1d54779 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:16:01 -0500 Subject: [PATCH 11/20] fix(infra): the quality cap wins over a pick, and renditions go by height on an uneven ladder A constant quality ladder can give a taller rendition less bandwidth, so the cap, the fallback and the menu order are tested against one. --- .../__tests__/unit/cap-player-source.test.ts | 28 ++++++++++++++ .../unit/developer-video-playback.test.ts | 37 +++++++++++++++++++ apps/web/lib/developer-video-playback.ts | 22 ++++++----- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/web/__tests__/unit/cap-player-source.test.ts b/apps/web/__tests__/unit/cap-player-source.test.ts index e3690ecf2e..70bf82b372 100644 --- a/apps/web/__tests__/unit/cap-player-source.test.ts +++ b/apps/web/__tests__/unit/cap-player-source.test.ts @@ -1165,6 +1165,34 @@ describe("native HLS renditions", () => { ).toEqual(["1080p", "720p", "360p"]); }); + it("orders the menu by height when bandwidth does not rise with it", () => { + iphone(true); + const uneven = [ + "#EXTM3U", + '#EXT-X-STREAM-INF:BANDWIDTH=1600000,RESOLUTION=3840x2160,AUDIO="aud"', + "2160p.m3u8", + '#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=1920x1080,AUDIO="aud"', + "1080p.m3u8", + '#EXT-X-STREAM-INF:BANDWIDTH=1900000,RESOLUTION=1280x720,AUDIO="aud"', + "720p.m3u8", + '#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=640x360,AUDIO="aud"', + "360p.m3u8", + ].join("\n"); + const levels = parseMasterLevels(uneven); + const menu = listLevels(levels); + + expect(menu.map((level) => level.label)).toEqual([ + "4K", + "1080p", + "720p", + "360p", + ]); + const picked = menu.find((level) => level.label === "1080p"); + expect(renditionSource(HLS, levels[picked?.index ?? -1]?.uri)).toBe( + `${HLS}?rendition=1080p`, + ); + }); + it("skips variants that are not Cap renditions", () => { const odd = [ "#EXTM3U", diff --git a/apps/web/__tests__/unit/developer-video-playback.test.ts b/apps/web/__tests__/unit/developer-video-playback.test.ts index 71af51ef6e..9537489ea9 100644 --- a/apps/web/__tests__/unit/developer-video-playback.test.ts +++ b/apps/web/__tests__/unit/developer-video-playback.test.ts @@ -591,6 +591,43 @@ describe("developer video playback route", () => { ]); }); + describe("on a constant quality ladder, where bandwidth does not rise with height", () => { + const uneven = ladder( + variant("2160p", 1_600_000, "3840x2160"), + variant("1080p", 800_000, "1920x1080"), + variant("720p", 1_900_000, "1280x720"), + variant("360p", 900_000, "640x360"), + ); + const names = (body: string) => order(body).sort(); + + beforeEach(() => { + mocks.readText.mockResolvedValue(uneven); + }); + + it("caps by height, not bandwidth", async () => { + expect(names(await served("?max=1080"))).toEqual([ + "1080p.m3u8", + "360p.m3u8", + "720p.m3u8", + ]); + expect(names(await served("?max=720"))).toEqual([ + "360p.m3u8", + "720p.m3u8", + ]); + }); + + it("falls back to the shortest rendition, not the cheapest", async () => { + expect(order(await served("?max=240"))).toEqual(["360p.m3u8"]); + }); + + it("picks a rendition by its name whatever its bandwidth", async () => { + expect(order(await served("?rendition=1080p"))).toEqual(["1080p.m3u8"]); + expect(names(await served("?rendition=2160p&max=1080"))).toEqual( + names(await served("?max=1080")), + ); + }); + }); + it.each([ "?rendition=480p", "?rendition=audio", diff --git a/apps/web/lib/developer-video-playback.ts b/apps/web/lib/developer-video-playback.ts index 0c8f42b7a0..c21e142dc7 100644 --- a/apps/web/lib/developer-video-playback.ts +++ b/apps/web/lib/developer-video-playback.ts @@ -221,8 +221,10 @@ export function renditionChoice(request: Request): { // player offers no way to pick a rendition, so the hosted player picks one by // loading a master that lists only it; the audio rendition stays listed, which // a rendition's own playlist would leave out. A cap keeps every rendition no -// taller than it, or the smallest when none is. A choice that matches nothing -// leaves the master as it is. +// taller than it, or the shortest when none is, and a pick is made only among +// those. Height comes from the rendition's name, never from its bandwidth: the +// ladder is constant quality, so a taller rendition can need less. A choice +// that matches nothing leaves the master as it is. export function selectRenditions( master: string, choice: { rendition: string | null; max: number | null }, @@ -252,24 +254,24 @@ export function selectRenditions( if (variants.length === 0) return master; let kept = variants; - if (choice.rendition !== null) { - const named = variants.filter( - (variant) => variant.uri === `${choice.rendition}.m3u8`, - ); - if (named.length > 0) kept = named; - } if (choice.max !== null) { const max = choice.max; - const below = kept.filter((variant) => variant.height <= max); + const below = variants.filter((variant) => variant.height <= max); kept = below.length > 0 ? below : [ - kept.reduce((low, variant) => + variants.reduce((low, variant) => variant.height < low.height ? variant : low, ), ]; } + if (choice.rendition !== null) { + const named = kept.filter( + (variant) => variant.uri === `${choice.rendition}.m3u8`, + ); + if (named.length > 0) kept = named; + } if (kept.length === variants.length) return master; return `${[...others, ...kept.flatMap((variant) => variant.lines)].join("\n")}\n`; } From 494b2bea9960493d46813a0c79413999a3b5e7f6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:16:01 -0500 Subject: [PATCH 12/20] fix(infra): leave the embed snippet's iframe attributes as they are The console options change only the embed URL. The new docs examples use allow="autoplay" with allowfullscreen. --- .../__tests__/unit/infra-video-view.test.ts | 21 +++++++++++-------- apps/web/content/docs/api/rest-api.mdx | 4 ++-- apps/web/lib/infra-video-view.ts | 5 +---- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/web/__tests__/unit/infra-video-view.test.ts b/apps/web/__tests__/unit/infra-video-view.test.ts index 8d0ff87985..a74bb03ea2 100644 --- a/apps/web/__tests__/unit/infra-video-view.test.ts +++ b/apps/web/__tests__/unit/infra-video-view.test.ts @@ -300,18 +300,21 @@ describe("console video payload", () => { ).toBe(`${PAGE}/embed?captions=1`); }); - it("lets autoplay through the iframe only when the snippet autoplays", () => { + it("keeps the iframe's attributes whatever the options", () => { const video = { name: "Demo", width: 1920, height: 1080 }; + const attributes = (code: string) => code.replace(/ src="[^"]*"/, ""); expect( - infraVideoEmbedCode(`${PAGE}/embed`, video, { - ...DEFAULT_EMBED_OPTIONS, - autoplay: true, - }), - ).toContain('allow="autoplay; fullscreen; picture-in-picture"'); - expect(infraVideoEmbedCode(`${PAGE}/embed`, video)).toContain( - 'allow="fullscreen; picture-in-picture"', - ); + attributes( + infraVideoEmbedCode(`${PAGE}/embed`, video, { + color: "#f97316", + controls: false, + autoplay: true, + captions: true, + qualityMenu: false, + }), + ), + ).toBe(attributes(infraVideoEmbedCode(`${PAGE}/embed`, video))); }); it.each([ diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 01228a2c1b..9b86e3971b 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1533,7 +1533,7 @@ The player has no title of its own, so there is nothing to hide. The quality men src="https://cap.so/dev/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/embed?color=f97316&radius=12&quality=1080&speed_menu=0&pip=0" title="Product Demo" style="width: 100%; aspect-ratio: 16 / 9; border: 0; border-radius: 12px" - allow="fullscreen; picture-in-picture" + allow="autoplay" allowfullscreen loading="lazy" > @@ -1567,7 +1567,7 @@ Commands are sent to the iframe's `contentWindow` as `{ type: "cap-player", comm | `setQuality` | `quality` | `"auto"`, or a rendition such as `"720p"`, `720` or `"4k"`. A rendition the video does not have is ignored | ```html - +