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}
/>