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..ed3e627384 --- /dev/null +++ b/apps/web/__tests__/unit/cap-player-messages.test.ts @@ -0,0 +1,383 @@ +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; + currentSrc = "blob:attached"; + + getAttribute() { + return null; + } + + 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 = ( + options: { seekDuringSwitch?: (time: number) => boolean } = {}, +) => { + 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, seekDuringSwitch: options.seekDuringSwitch }, + ); + 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("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("holds a play sent before the source is attached until it is", async () => { + const { video, command, events } = embedded(); + video.currentSrc = ""; + + command({ type: "cap-player", command: "play" }); + await Promise.resolve(); + expect(video.plays).toBe(0); + + video.currentSrc = "blob:attached"; + video.dispatchEvent(new Event("loadedmetadata")); + await Promise.resolve(); + + expect(video.plays).toBe(1); + expect(video.paused).toBe(false); + expect(events()).toEqual(["ready"]); + }); + + it("drops a held play when a pause follows it", async () => { + const { video, command } = embedded(); + video.currentSrc = ""; + + command({ type: "cap-player", command: "play" }); + command({ type: "cap-player", command: "pause" }); + video.currentSrc = "blob:attached"; + video.dispatchEvent(new Event("loadedmetadata")); + await Promise.resolve(); + + expect(video.plays).toBe(0); + }); + + it("hands a seek to a quality switch that is loading", () => { + const seekDuringSwitch = vi.fn(() => true); + const { video, command } = embedded({ seekDuringSwitch }); + video.duration = Number.NaN; + + command({ type: "cap-player", command: "seek", time: 8 }); + video.duration = 30; + video.dispatchEvent(new Event("loadedmetadata")); + + expect(seekDuringSwitch).toHaveBeenCalledWith(8); + expect(video.currentTime).toBe(0); + }); + + 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("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(); + + 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/__tests__/unit/cap-player-source.test.ts b/apps/web/__tests__/unit/cap-player-source.test.ts index 0797af41da..89ab101914 100644 --- a/apps/web/__tests__/unit/cap-player-source.test.ts +++ b/apps/web/__tests__/unit/cap-player-source.test.ts @@ -1,11 +1,14 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + AUTO_LEVEL, attachSource, isSharper, listLevels, + parseMasterLevels, pickPlaybackMode, pickStartingLevel, playAllowingMuted, + renditionSource, SECONDS_AHEAD_BEFORE_PLAY, SECONDS_TO_SHARPEN, startingEstimate, @@ -135,6 +138,13 @@ const browser = (options: { }); }; +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("offline"))), + ); +}); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -290,6 +300,7 @@ describe("pickPlaybackMode", () => { }); class FakeVideo extends EventTarget { + playbackRate = 1; preload = "none"; src = ""; paused = true; @@ -1255,3 +1266,339 @@ describe("attachSource switching to the rendition that fits", () => { expect(hls.currentLevel).toBe(2); }); }); + +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("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("lists native renditions in the same order and labels as hls.js does", () => { + iphone(true); + const uneven = [ + "#EXTM3U", + '#EXT-X-STREAM-INF:BANDWIDTH=1900000,RESOLUTION=1280x720,AUDIO="aud"', + "720p.m3u8", + '#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=1920x1080,AUDIO="aud"', + "1080p.m3u8", + '#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=640x360,AUDIO="aud"', + "360p.m3u8", + '#EXT-X-STREAM-INF:BANDWIDTH=1600000,RESOLUTION=3840x2160,AUDIO="aud"', + "2160p.m3u8", + ].join("\n"); + const native = listLevels(parseMasterLevels(uneven)); + const hlsJs = listLevels( + [...parseMasterLevels(uneven)] + .sort((a, b) => a.bitrate - b.bitrate) + .map(({ width, height, bitrate, uri }) => ({ + width, + height, + bitrate, + name: uri.replace(".m3u8", ""), + })), + ); + + expect(native.map((level) => level.label)).toEqual([ + "4K", + "1080p", + "720p", + "360p", + ]); + expect(native.map((level) => level.label)).toEqual( + hlsJs.map((level) => level.label), + ); + }); + + 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("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("keeps the place across picks made before the first switch has loaded", async () => { + const { video, setLevel, index } = await nativePlayer(); + await video.play(); + video.currentTime = 12.5; + + setLevel(index("360p")); + video.currentTime = 0; + setLevel(index("1080p")); + + expect(video.src).toBe(`${HLS}?rendition=1080p`); + expect(video.plays).toBe(3); + video.reach(30); + expect(video.currentTime).toBe(12.5); + + video.currentTime = 20; + setLevel(AUTO_LEVEL); + video.currentTime = 0; + video.reach(30); + expect(video.currentTime).toBe(20); + }); + + it("keeps a pause made while a switch loads through the next pick", async () => { + const { video, setLevel, index } = await nativePlayer(); + await video.play(); + video.currentTime = 12.5; + + setLevel(index("360p")); + expect(video.plays).toBe(2); + video.currentTime = 0; + video.paused = true; + setLevel(index("1080p")); + + expect(video.plays).toBe(2); + video.reach(30); + expect(video.currentTime).toBe(12.5); + expect(video.paused).toBe(true); + }); + + it("resumes a switch at a seek asked for while it loads", async () => { + const { video, setLevel, index, seekDuringSwitch } = await nativePlayer(); + await video.play(); + video.currentTime = 12.5; + + expect(seekDuringSwitch(3)).toBe(false); + setLevel(index("360p")); + expect(seekDuringSwitch(18)).toBe(true); + video.currentTime = 0; + video.reach(30); + + expect(video.currentTime).toBe(18); + expect(seekDuringSwitch(5)).toBe(false); + }); + + it("keeps a switch's place, and a seek asked for during it, when it falls back to the MP4", async () => { + const { video, setLevel, index, seekDuringSwitch } = await nativePlayer(); + await video.play(); + video.currentTime = 12.5; + + setLevel(index("360p")); + expect(seekDuringSwitch(18)).toBe(true); + video.currentTime = 0; + video.dispatchEvent(new Event("error")); + + expect(video.src).toBe(MP4); + expect(video.currentTime).toBe(18); + expect(seekDuringSwitch(5)).toBe(false); + }); + + it("keeps the playback speed across a quality switch", async () => { + const { video, setLevel, index } = await nativePlayer(); + video.playbackRate = 1.5; + const reset = Object.getOwnPropertyDescriptor(video, "src"); + let src = video.src; + Object.defineProperty(video, "src", { + get: () => src, + set: (value: string) => { + src = value; + video.playbackRate = 1; + }, + configurable: true, + }); + + setLevel(index("360p")); + expect(video.playbackRate).toBe(1.5); + video.playbackRate = 1; + video.reach(30); + expect(video.playbackRate).toBe(1.5); + if (reset) Object.defineProperty(video, "src", reset); + }); + + 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/__tests__/unit/developer-video-embed-error.test.tsx b/apps/web/__tests__/unit/developer-video-embed-error.test.tsx new file mode 100644 index 0000000000..63b846fc59 --- /dev/null +++ b/apps/web/__tests__/unit/developer-video-embed-error.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EmbedError } from "@/app/dev/_components/EmbedError"; + +const realParent = window.parent; + +const mount = (code: "unavailable" | "paused") => { + const container = document.createElement("div"); + const root = createRoot(container); + act(() => root.render(createElement(EmbedError, { code }))); + return { container, unmount: () => act(() => root.unmount()) }; +}; + +afterEach(() => { + Object.defineProperty(window, "parent", { + value: realParent, + configurable: true, + }); +}); + +describe("EmbedError", () => { + it("tells the embedding page there is nothing to play, with no video details", () => { + const postMessage = vi.fn(); + Object.defineProperty(window, "parent", { + value: { postMessage }, + configurable: true, + }); + + const { container, unmount } = mount("unavailable"); + + expect(container.innerHTML).toBe(""); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith( + { + type: "cap-player", + event: "error", + currentTime: 0, + duration: 0, + paused: true, + muted: false, + code: "unavailable", + }, + "*", + ); + unmount(); + }); + + it("says so for a paused app", () => { + const postMessage = vi.fn(); + Object.defineProperty(window, "parent", { + value: { postMessage }, + configurable: true, + }); + + const { unmount } = mount("paused"); + + expect(postMessage.mock.calls[0]?.[0]).toMatchObject({ + event: "error", + code: "paused", + }); + unmount(); + }); + + it("posts nothing when the page is not embedded", () => { + const spy = vi.spyOn(window, "postMessage"); + + const { unmount } = mount("unavailable"); + + expect(spy).not.toHaveBeenCalled(); + unmount(); + spy.mockRestore(); + }); +}); diff --git a/apps/web/__tests__/unit/developer-video-page.test.ts b/apps/web/__tests__/unit/developer-video-page.test.ts index 819c3ed909..972e03323a 100644 --- a/apps/web/__tests__/unit/developer-video-page.test.ts +++ b/apps/web/__tests__/unit/developer-video-page.test.ts @@ -269,6 +269,43 @@ describe("a taken down video page", () => { }, ); + it("ignores every embed parameter", async () => { + mocks.load.mockResolvedValue(takenDownFromLoader); + const plain = renderToStaticMarkup( + await DeveloperVideoPage({ + playbackId: PLAYBACK_ID, + variant: "embed", + query: {}, + }), + ); + const styled = renderToStaticMarkup( + await DeveloperVideoPage({ + playbackId: PLAYBACK_ID, + variant: "embed", + query: { + autoplay: "1", + muted: "1", + controls: "0", + t: "30", + captions: "1", + color: "f97316", + radius: "16", + quality: "720", + quality_menu: "0", + speed_menu: "0", + pip: "0", + fullscreen: "0", + poster: "0", + keyboard: "0", + }, + }), + ); + + expect(styled).toBe(plain); + expect(styled).not.toContain("f97316"); + expect(styled).not.toContain("border-radius"); + }); + it("never renders the video's own name or size even if the loader passed them", () => { const html = render(takenDown, "share"); @@ -382,8 +419,76 @@ 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.each([ + ["failed", failed], + ["paused", paused], + ])( + "keeps the %s embed square and opaque whatever the radius", + (_, player) => { + const html = render( + player, + "embed", + developerVideoPlayback({ radius: "12" }, "embed"), + ); + + expect(html).not.toContain("border-radius"); + expect(html).not.toContain("background:transparent"); + }, + ); + + 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..d2b6dae2c1 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,16 @@ const DEFAULTS = { controls: true, captions: false, startAt: 0, + accentColor: null, + radius: 0, + maxQuality: null, + poster: true, + keyboard: true, + events: true, + qualityMenu: true, + speedMenu: true, + pip: true, + fullscreen: true, }; describe("developerVideoPlayback", () => { @@ -29,6 +42,7 @@ describe("developerVideoPlayback", () => { "embed", ), ).toEqual({ + ...DEFAULTS, autoPlay: true, muted: true, loop: true, @@ -144,6 +158,142 @@ 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, + qualityMenu: false, + speedMenu: false, + pip: false, + fullscreen: false, + }); + }); + + 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", + ), + ).toMatchObject({ + qualityMenu: false, + speedMenu: true, + pip: true, + fullscreen: false, + }); + }); + + 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, + pip: true, + }); + }); +}); + +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/__tests__/unit/developer-video-playback.test.ts b/apps/web/__tests__/unit/developer-video-playback.test.ts index c167730be7..9537489ea9 100644 --- a/apps/web/__tests__/unit/developer-video-playback.test.ts +++ b/apps/web/__tests__/unit/developer-video-playback.test.ts @@ -541,6 +541,109 @@ 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", + ]); + }); + + 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", + "?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/__tests__/unit/infra-video-view.test.ts b/apps/web/__tests__/unit/infra-video-view.test.ts index d940b8a9f9..3e629f7143 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,68 @@ 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("keeps the iframe's attributes whatever the options", () => { + const video = { name: "Demo", width: 1920, height: 1080 }; + const attributes = (code: string) => code.replace(/ src="[^"]*"/, ""); + + expect( + attributes( + infraVideoEmbedCode(`${PAGE}/embed`, video, { + color: "#f97316", + controls: false, + autoplay: true, + captions: true, + qualityMenu: false, + }), + ), + ).toBe(attributes(infraVideoEmbedCode(`${PAGE}/embed`, video))); + }); + + 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/app/dev/_components/DeveloperVideoPage.tsx b/apps/web/app/dev/_components/DeveloperVideoPage.tsx index 370f740119..7eee977444 100644 --- a/apps/web/app/dev/_components/DeveloperVideoPage.tsx +++ b/apps/web/app/dev/_components/DeveloperVideoPage.tsx @@ -2,9 +2,13 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { cache } from "react"; import { preload } from "react-dom"; -import { CapPlayer } from "@/components/cap-player/CapPlayer"; +import { + CapPlayer, + type CapPlayerControl, +} from "@/components/cap-player/CapPlayer"; import { Lockup } from "@/components/infra/Lockup"; import { + cappedPlaylistUrl, type DeveloperVideoPlayback, type DeveloperVideoQuery, developerVideoPlayback, @@ -18,6 +22,7 @@ import { developerVideoReportHref, } from "@/lib/developer-video-takedown"; import { INFRA_VIDEO_PUBLIC_FAILURE } from "@/lib/infra-video-view"; +import { EmbedError } from "./EmbedError"; import { RefreshUntilReady } from "./RefreshUntilReady"; export type DeveloperVideoPageVariant = "share" | "embed"; @@ -81,6 +86,15 @@ function Notice({ ); } +const hiddenControls = (playback: DeveloperVideoPlayback) => { + const hidden: CapPlayerControl[] = []; + if (!playback.qualityMenu) hidden.push("quality"); + if (!playback.speedMenu) hidden.push("speed"); + if (!playback.pip) hidden.push("pip"); + if (!playback.fullscreen) hidden.push("fullscreen"); + return hidden; +}; + function Stage({ player, playback, @@ -89,13 +103,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 ( ); } if (player.kind === "paused") { return ( - + <> + + {playback.events && } + ); } if (player.kind === "failed") { - return ; + return ( + <> + + {playback.events && } + + ); } if (player.kind === "taken_down") { - return ; + return ( + <> + + {playback.events && } + + ); } return ( <> @@ -168,6 +204,7 @@ export function DeveloperVideoView({

{DEVELOPER_VIDEO_UNAVAILABLE}

+ {variant === "embed" && } ); } @@ -175,9 +212,21 @@ export function DeveloperVideoView({ const reportHref = developerVideoReportHref(playbackId); if (variant === "embed") { + const rounded = playback.radius > 0 && player.kind === "playing"; return ( -
- +
+

{player.name}

{ + if (window.parent === window) return; + window.parent.postMessage( + { + type: MESSAGE_TYPE, + event: "error", + currentTime: 0, + duration: 0, + paused: true, + muted: false, + code, + }, + "*", + ); + }, [code]); + return null; +} diff --git a/apps/web/components/cap-player/CapPlayer.tsx b/apps/web/components/cap-player/CapPlayer.tsx index f7e3dce1a8..3019d12014 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,17 @@ 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 pendingQuality = useRef(null); const [playing, setPlaying] = useState(false); const [started, setStarted] = useState(autoPlay); @@ -656,6 +670,7 @@ export const CapPlayer = ({ void document.exitFullscreen(); return; } + if (!live.current.fullscreen) return; if (root.requestFullscreen) { void root.requestFullscreen().catch(() => undefined); return; @@ -800,6 +815,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 +1019,34 @@ 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) => { + if (levels.length === 0) { + pendingQuality.current = name; + return; + } + pendingQuality.current = null; + 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 +1089,50 @@ 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, { + setQuality: (name) => live.current.setQuality(name), + seekDuringSwitch: (time) => + attachmentRef.current?.seekDuringSwitch(time) ?? false, + }); + messages.current = connection; + return () => { + messages.current = null; + connection.stop(); + }; + }, [events]); + + useEffect(() => { + const pending = pendingQuality.current; + if (pending !== null && levels.length > 0) live.current.setQuality(pending); + }, [levels]); + + 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 interface PlayerActions { + setQuality: (quality: string) => void; + seekDuringSwitch?: (time: number) => boolean; +} + +export const connectMessages = ( + target: MessageTarget, + video: HTMLVideoElement, + actions: PlayerActions, +) => { + const send = ( + event: PlayerEventName, + 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) => { + if (actions.seekDuringSwitch?.(time)) { + pendingSeek = null; + return; + } + 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); + }; + // hls.js attaches its source only after it has loaded, and attaching a + // source cancels a play asked for before it. A play sent on ready waits + // for the source. + let pendingPlay = false; + const play = () => { + if (!video.currentSrc && !video.getAttribute("src")) { + pendingPlay = true; + return; + } + pendingPlay = false; + void video.play().catch((error: unknown) => { + if (error instanceof Error && error.name === "NotAllowedError") { + send("error", { code: "play_refused" }); + } + }); + }; + const playWhenAttached = () => { + if (pendingPlay) play(); + }; + + const onMessage = (message: MessageEvent) => { + if (message.source !== target.parent) return; + const parsed = parseCommand(message.data); + if (!parsed) return; + if (parsed.command === "play") play(); + else if (parsed.command === "pause") { + pendingPlay = false; + video.pause(); + } else if (parsed.command === "mute") video.muted = true; + else if (parsed.command === "unmute") video.muted = false; + else if (parsed.command === "seek") seek(parsed.time); + else if (parsed.command === "setQuality") { + actions.setQuality(parsed.quality); + } + }; + + const relayed: [string, () => void][] = [ + ["loadedmetadata", seekWhenKnown], + ["loadedmetadata", playWhenAttached], + ["durationchange", seekWhenKnown], + ["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); + }, + }; +}; diff --git a/apps/web/components/cap-player/source.ts b/apps/web/components/cap-player/source.ts index 73cef80c37..cff72b6cb4 100644 --- a/apps/web/components/cap-player/source.ts +++ b/apps/web/components/cap-player/source.ts @@ -17,6 +17,7 @@ export interface Attachment { stop: () => void; warm: () => void; setLevel: (index: number) => void; + seekDuringSwitch: (time: number) => boolean; } export interface AttachOptions { @@ -87,6 +88,48 @@ export const listLevels = ( return [...best.values()].sort(sharperFirst); }; +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"; // The line assumed before anything is measured. Chrome's connection hint // reads about 1.6 Mbps on a fast line as on a slow one, so it starts low, and @@ -261,8 +304,13 @@ export const attachSource = ( height: video.clientHeight * window.devicePixelRatio, }); + // While a native quality switch loads, the element reads time zero, so the + // switch holds the real place for a second pick, a seek or a fallback to + // the MP4. Whether it plays is read from the element each time: a pause in + // that window must hold. + let switchingFrom: number | null = null; const position = () => ({ - resumeAt: video.currentTime, + resumeAt: switchingFrom ?? video.currentTime, wasPlaying: !video.paused, }); @@ -288,11 +336,66 @@ export const attachSource = ( let fileAttached = false; let nativeAttached = false; + let nativeLevels: MasterLevel[] = []; + let nativeSrc = ""; + let listing: AbortController | null = null; + let resumeAfterSwitch: (() => void) | 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 dropResume() { + if (resumeAfterSwitch) { + video.removeEventListener("loadedmetadata", resumeAfterSwitch); + } + resumeAfterSwitch = null; + switchingFrom = null; + } + + 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 = switchingFrom ?? video.currentTime; + const wasPlaying = !video.paused; + const speed = video.playbackRate; + dropResume(); + nativeSrc = next; + video.src = next; + video.playbackRate = speed; + switchingFrom = resumeAt; + const resume = () => { + const at = switchingFrom ?? resumeAt; + resumeAfterSwitch = null; + switchingFrom = null; + video.playbackRate = speed; + if (at > 0) video.currentTime = at; + }; + resumeAfterSwitch = resume; + video.addEventListener("loadedmetadata", resume, { 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()) { @@ -303,11 +406,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; @@ -324,6 +429,7 @@ export const attachSource = ( return; } fileAttached = true; + dropResume(); setLevel = () => undefined; onLevels?.([]); video.removeEventListener("error", onNativeError); @@ -345,7 +451,10 @@ export const attachSource = ( const stop = () => { cancelled = true; cancelWarm(); + listing?.abort(); + dropResume(); video.removeEventListener("error", onNativeError); + video.removeEventListener("loadedmetadata", listNativeLevels); video.removeEventListener("loadedmetadata", seekToStart); detach(); }; @@ -360,10 +469,19 @@ export const attachSource = ( } }; + // A seek asked for while a quality switch loads becomes the place the + // switch resumes at, instead of being overwritten by the old one. + const seekDuringSwitch = (time: number) => { + if (resumeAfterSwitch === null) return false; + switchingFrom = time; + return true; + }; + const attachment: Attachment = { stop, warm, setLevel: (index) => setLevel(index), + seekDuringSwitch, }; if (mode === "none") { @@ -372,6 +490,7 @@ export const attachSource = ( stop: () => undefined, warm: () => undefined, setLevel: () => undefined, + seekDuringSwitch: () => false, }; } diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index e65abd670c..87237a8f04 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1519,6 +1519,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 stops being playable, the browser refused a `play` command, or the embed has nothing to play | `code`: `"media"` when playback fails, `"play_refused"`, `"unavailable"` for a video that is not available, or `"paused"` while the app's playback is paused. With `"unavailable"` or `"paused"`, no `ready` is sent and commands do nothing | + +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. diff --git a/apps/web/lib/developer-video-params.ts b/apps/web/lib/developer-video-params.ts index bc7db0878c..33ec66e35b 100644 --- a/apps/web/lib/developer-video-params.ts +++ b/apps/web/lib/developer-video-params.ts @@ -5,11 +5,27 @@ export interface DeveloperVideoPlayback { controls: boolean; captions: boolean; startAt: number; + accentColor: string | null; + radius: number; + maxQuality: number | null; + poster: boolean; + keyboard: boolean; + events: boolean; + qualityMenu: boolean; + speedMenu: boolean; + pip: boolean; + fullscreen: boolean; } export type DeveloperVideoQuery = Record; 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; +const MAX_EMBED_RADIUS = 24; +const LOWEST_QUALITY = 144; +const HIGHEST_QUALITY = 4320; const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; @@ -26,6 +42,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}`; +}; + +const cornerRadius = (value: string | string[] | undefined) => { + const text = first(value); + if (!text || !RADIUS_PATTERN.test(text)) return 0; + return Math.min(MAX_EMBED_RADIUS, Number(text)); +}; + +const qualityCap = (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 +73,28 @@ 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 ? cornerRadius(query.radius) : 0, + maxQuality: embed ? qualityCap(query.quality) : null, + poster: !embed || flag(query.poster, true), + keyboard: !embed || flag(query.keyboard, true), + events: embed, + qualityMenu: !embed || flag(query.quality_menu, true), + speedMenu: !embed || flag(query.speed_menu, true), + pip: !embed || flag(query.pip, true), + fullscreen: !embed || flag(query.fullscreen, true), }; } + +// 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; + } +} diff --git a/apps/web/lib/developer-video-playback.ts b/apps/web/lib/developer-video-playback.ts index 0486c64006..c21e142dc7 100644 --- a/apps/web/lib/developer-video-playback.ts +++ b/apps/web/lib/developer-video-playback.ts @@ -196,6 +196,86 @@ 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 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 }, +): 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.max !== null) { + const max = choice.max; + const below = variants.filter((variant) => variant.height <= max); + kept = + below.length > 0 + ? below + : [ + 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`; +} + // 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 +437,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( diff --git a/apps/web/lib/infra-video-view.ts b/apps/web/lib/infra-video-view.ts index 328489d89c..4058f1b3e4 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,7 @@ export function infraVideoEmbedCode( video.width > 0 && video.height > 0; const shape = measured ? `${video.width} / ${video.height}` : "16 / 9"; - return ``; + return ``; } export function toInfraVideoDetail(