From 975536af41ee70862fe0e5122ea1c51ac3aa0767 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:09:28 +0100 Subject: [PATCH] fix: honor playback speed settings in embeds --- .../unit/embed-playback-speed.test.ts | 106 ++++++++++++++++++ .../unit/embed-video-playback-chrome.test.ts | 56 ++++++++- .../[videoId]/_components/EmbedVideo.tsx | 4 + apps/web/app/embed/[videoId]/page.tsx | 5 + 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 apps/web/__tests__/unit/embed-playback-speed.test.ts diff --git a/apps/web/__tests__/unit/embed-playback-speed.test.ts b/apps/web/__tests__/unit/embed-playback-speed.test.ts new file mode 100644 index 0000000000..ef1b41d2a6 --- /dev/null +++ b/apps/web/__tests__/unit/embed-playback-speed.test.ts @@ -0,0 +1,106 @@ +import { Context, Effect } from "effect"; +import { isValidElement, type ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ select: vi.fn() })); + +vi.mock("@cap/database", () => ({ db: () => ({ select: mocks.select }) })); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => null, +})); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.so" }, +})); +vi.mock("@cap/ui", () => ({ Logo: () => null })); +vi.mock("@cap/utils", () => ({ userIsPro: () => true })); +vi.mock("@cap/web-backend", () => ({ + VideosPolicy: Context.GenericTag("EmbedTestPolicy"), + provideOptionalAuth: (effect: Effect.Effect) => effect, + resolveEffectiveVideoRules: () => ({ settings: {} }), +})); +vi.mock("@/lib/server", () => ({ + runPromise: (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(Context.GenericTag("EmbedTestPolicy"), { + canView: () => Effect.void, + }), + Effect.runPromise, + ), +})); +vi.mock("@/lib/shareable-link-quota", () => ({ + isVideoOverShareableLinkLimit: async () => false, +})); +vi.mock("@/lib/transcribe", () => ({ transcribeVideo: vi.fn() })); +vi.mock("@/utils/flags", () => ({ isAiGenerationEnabled: async () => false })); +vi.mock("@/app/embed/[videoId]/_components/PasswordOverlay", () => ({ + PasswordOverlay: () => null, +})); +vi.mock("@/app/embed/[videoId]/_components/EmbedVideo", () => ({ + EmbedVideo: () => null, +})); + +import EmbedVideoPage from "@/app/embed/[videoId]/page"; + +async function renderEmbed( + videoSpeed: number | undefined, + orgSpeed: number | undefined, + minimal = false, +) { + const video = { + id: "video", + ownerId: "owner", + settings: + videoSpeed === undefined ? null : { defaultPlaybackSpeed: videoSpeed }, + orgSettings: + orgSpeed === undefined ? null : { defaultPlaybackSpeed: orgSpeed }, + transcriptionStatus: "COMPLETE", + }; + mocks.select.mockImplementation((selection: Record) => { + const rows = + "ownerId" in selection + ? [video] + : "email" in selection + ? [{ email: "owner@example.com" }] + : []; + const query = { + from: () => query, + leftJoin: () => query, + innerJoin: () => query, + where: () => Object.assign(Promise.resolve(rows), { limit: () => rows }), + }; + return query; + }); + + const page = (await EmbedVideoPage({ + params: Promise.resolve({ videoId: "video" }), + searchParams: Promise.resolve(minimal ? { slack: "true" } : {}), + })) as ReactElement<{ children: unknown[] }>; + const content = page.props.children[1]; + if (!isValidElement(content) || typeof content.type !== "function") { + throw new Error("Expected authorized embed content"); + } + return (await (content.type as (props: unknown) => Promise)( + content.props, + )) as ReactElement<{ defaultPlaybackSpeed?: number; minimal: boolean }>; +} + +describe("embed default playback speed", () => { + it.each([ + { name: "organization 1×", video: undefined, org: 1, expected: 1 }, + { name: "video override", video: 1.5, org: 1, expected: 1.5 }, + { name: "video 1× override", video: 1, org: 1.5, expected: 1 }, + { name: "no settings", video: undefined, org: undefined, expected: 1.2 }, + { name: "invalid video speed", video: 0, org: 1, expected: 1 }, + { name: "invalid speeds", video: -1, org: 0, expected: 1.2 }, + { name: "legacy speed normalization", video: 1.3, org: 1, expected: 1.2 }, + ])("resolves $name", async ({ video, org, expected }) => { + const embed = await renderEmbed(video, org); + expect(embed.props.defaultPlaybackSpeed).toBe(expected); + }); + + it("uses the organization speed for minimal embeds", async () => { + const embed = await renderEmbed(undefined, 1, true); + expect(embed.props.minimal).toBe(true); + expect(embed.props.defaultPlaybackSpeed).toBe(1); + }); +}); diff --git a/apps/web/__tests__/unit/embed-video-playback-chrome.test.ts b/apps/web/__tests__/unit/embed-video-playback-chrome.test.ts index 53704aaa03..fac9bc0aa5 100644 --- a/apps/web/__tests__/unit/embed-video-playback-chrome.test.ts +++ b/apps/web/__tests__/unit/embed-video-playback-chrome.test.ts @@ -83,12 +83,19 @@ vi.mock("@/app/s/[videoId]/_components/CapVideoPlayer", async () => { const { createElement, useEffect, useState } = await import("react"); const DeferredVideoPlayer = ({ videoRef, + defaultPlaybackSpeed, }: { videoRef: Ref; + defaultPlaybackSpeed?: number; }) => { const [isMounted, setIsMounted] = useState(false); useEffect(() => setIsMounted(true), []); - return isMounted ? createElement("video", { ref: videoRef }) : null; + return isMounted + ? createElement("video", { + ref: videoRef, + "data-default-speed": defaultPlaybackSpeed, + }) + : null; }; return { CapVideoPlayer: DeferredVideoPlayer }; }); @@ -96,8 +103,17 @@ vi.mock("@/app/s/[videoId]/_components/CapVideoPlayer", async () => { vi.mock("@/app/s/[videoId]/_components/HLSVideoPlayer", async () => { const { createElement } = await import("react"); return { - HLSVideoPlayer: ({ videoRef }: { videoRef: Ref }) => - createElement("video", { ref: videoRef }), + HLSVideoPlayer: ({ + videoRef, + defaultPlaybackSpeed, + }: { + videoRef: Ref; + defaultPlaybackSpeed?: number; + }) => + createElement("video", { + ref: videoRef, + "data-default-speed": defaultPlaybackSpeed, + }), }; }); @@ -173,6 +189,40 @@ describe("EmbedVideo playback chrome", () => { delete actEnvironment.IS_REACT_ACT_ENVIRONMENT; }); + it.each( + ["desktopMP4", "webMP4", "MediaConvert", "desktopSegments"].flatMap( + (type) => + [false, true].flatMap((minimal) => + [1, 1.5].map((speed) => ({ type, minimal, speed })), + ), + ), + )( + "passes $speed× to $type playback with minimal=$minimal", + async ({ type, minimal, speed }) => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const props = createProps({ + type, + } as EmbedVideoProps["data"]["source"]); + + await act(async () => { + root.render( + createElement(EmbedVideo, { + ...props, + minimal, + defaultPlaybackSpeed: speed, + }), + ); + }); + expect(container.querySelector("video")?.dataset.defaultSpeed).toBe( + String(speed), + ); + + await act(async () => root.unmount()); + }, + ); + it.each([ ["an asynchronously mounted MP4", { type: "desktopMP4" } as const], ["an HLS video", { type: "MediaConvert" } as const], diff --git a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx index 803809d9ab..0fb5c9079f 100644 --- a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx +++ b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx @@ -64,6 +64,7 @@ export const EmbedVideo = forwardRef< /** Seconds to open at, from the embed URL's `?t=`. */ startTime?: number | null; minimal?: boolean; + defaultPlaybackSpeed?: number; viewerSettings?: ViewerSettings | null; showPlaybackStatusBadge?: boolean; } @@ -79,6 +80,7 @@ export const EmbedVideo = forwardRef< autoplay = false, startTime = null, minimal = false, + defaultPlaybackSpeed, viewerSettings, showPlaybackStatusBadge = false, }, @@ -274,6 +276,7 @@ export const EmbedVideo = forwardRef< captionsSrc={captionsDisabled ? "" : subtitleUrl || ""} videoRef={videoRef} autoplay={autoplay} + defaultPlaybackSpeed={defaultPlaybackSpeed} enableCrossOrigin={enableCrossOrigin} hasActiveUpload={data.hasActiveUpload} /> @@ -288,6 +291,7 @@ export const EmbedVideo = forwardRef< captionsSrc={captionsDisabled ? "" : subtitleUrl || ""} videoRef={videoRef} autoplay={autoplay} + defaultPlaybackSpeed={defaultPlaybackSpeed} hasActiveUpload={data.hasActiveUpload} isLiveSegments={isSegmentsSource} /> diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx index e1f742300b..14f07c210d 100644 --- a/apps/web/app/embed/[videoId]/page.tsx +++ b/apps/web/app/embed/[videoId]/page.tsx @@ -27,6 +27,7 @@ import { Effect, Option } from "effect"; import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { resolveDefaultPlaybackSpeed } from "@/lib/playback-speed"; import * as EffectRuntime from "@/lib/server"; import { getSharePageBranding } from "@/lib/share-branding"; import { buildShareVideoMetadata } from "@/lib/share-video-metadata"; @@ -392,6 +393,10 @@ async function EmbedContent({ autoplay={autoplay} startTime={startTime} minimal={minimal} + defaultPlaybackSpeed={resolveDefaultPlaybackSpeed( + video.settings?.defaultPlaybackSpeed, + video.orgSettings?.defaultPlaybackSpeed, + )} viewerSettings={rules.settings} showPlaybackStatusBadge={user?.id === video.ownerId} />