Skip to content

Commit 975536a

Browse files
committed
fix: honor playback speed settings in embeds
1 parent 284ae24 commit 975536a

4 files changed

Lines changed: 168 additions & 3 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Context, Effect } from "effect";
2+
import { isValidElement, type ReactElement } from "react";
3+
import { describe, expect, it, vi } from "vitest";
4+
5+
const mocks = vi.hoisted(() => ({ select: vi.fn() }));
6+
7+
vi.mock("@cap/database", () => ({ db: () => ({ select: mocks.select }) }));
8+
vi.mock("@cap/database/auth/session", () => ({
9+
getCurrentUser: async () => null,
10+
}));
11+
vi.mock("@cap/env", () => ({
12+
buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.so" },
13+
}));
14+
vi.mock("@cap/ui", () => ({ Logo: () => null }));
15+
vi.mock("@cap/utils", () => ({ userIsPro: () => true }));
16+
vi.mock("@cap/web-backend", () => ({
17+
VideosPolicy: Context.GenericTag("EmbedTestPolicy"),
18+
provideOptionalAuth: <A, E, R>(effect: Effect.Effect<A, E, R>) => effect,
19+
resolveEffectiveVideoRules: () => ({ settings: {} }),
20+
}));
21+
vi.mock("@/lib/server", () => ({
22+
runPromise: <A, E>(effect: Effect.Effect<A, E, unknown>) =>
23+
effect.pipe(
24+
Effect.provideService(Context.GenericTag("EmbedTestPolicy"), {
25+
canView: () => Effect.void,
26+
}),
27+
Effect.runPromise,
28+
),
29+
}));
30+
vi.mock("@/lib/shareable-link-quota", () => ({
31+
isVideoOverShareableLinkLimit: async () => false,
32+
}));
33+
vi.mock("@/lib/transcribe", () => ({ transcribeVideo: vi.fn() }));
34+
vi.mock("@/utils/flags", () => ({ isAiGenerationEnabled: async () => false }));
35+
vi.mock("@/app/embed/[videoId]/_components/PasswordOverlay", () => ({
36+
PasswordOverlay: () => null,
37+
}));
38+
vi.mock("@/app/embed/[videoId]/_components/EmbedVideo", () => ({
39+
EmbedVideo: () => null,
40+
}));
41+
42+
import EmbedVideoPage from "@/app/embed/[videoId]/page";
43+
44+
async function renderEmbed(
45+
videoSpeed: number | undefined,
46+
orgSpeed: number | undefined,
47+
minimal = false,
48+
) {
49+
const video = {
50+
id: "video",
51+
ownerId: "owner",
52+
settings:
53+
videoSpeed === undefined ? null : { defaultPlaybackSpeed: videoSpeed },
54+
orgSettings:
55+
orgSpeed === undefined ? null : { defaultPlaybackSpeed: orgSpeed },
56+
transcriptionStatus: "COMPLETE",
57+
};
58+
mocks.select.mockImplementation((selection: Record<string, unknown>) => {
59+
const rows =
60+
"ownerId" in selection
61+
? [video]
62+
: "email" in selection
63+
? [{ email: "owner@example.com" }]
64+
: [];
65+
const query = {
66+
from: () => query,
67+
leftJoin: () => query,
68+
innerJoin: () => query,
69+
where: () => Object.assign(Promise.resolve(rows), { limit: () => rows }),
70+
};
71+
return query;
72+
});
73+
74+
const page = (await EmbedVideoPage({
75+
params: Promise.resolve({ videoId: "video" }),
76+
searchParams: Promise.resolve(minimal ? { slack: "true" } : {}),
77+
})) as ReactElement<{ children: unknown[] }>;
78+
const content = page.props.children[1];
79+
if (!isValidElement(content) || typeof content.type !== "function") {
80+
throw new Error("Expected authorized embed content");
81+
}
82+
return (await (content.type as (props: unknown) => Promise<unknown>)(
83+
content.props,
84+
)) as ReactElement<{ defaultPlaybackSpeed?: number; minimal: boolean }>;
85+
}
86+
87+
describe("embed default playback speed", () => {
88+
it.each([
89+
{ name: "organization 1×", video: undefined, org: 1, expected: 1 },
90+
{ name: "video override", video: 1.5, org: 1, expected: 1.5 },
91+
{ name: "video 1× override", video: 1, org: 1.5, expected: 1 },
92+
{ name: "no settings", video: undefined, org: undefined, expected: 1.2 },
93+
{ name: "invalid video speed", video: 0, org: 1, expected: 1 },
94+
{ name: "invalid speeds", video: -1, org: 0, expected: 1.2 },
95+
{ name: "legacy speed normalization", video: 1.3, org: 1, expected: 1.2 },
96+
])("resolves $name", async ({ video, org, expected }) => {
97+
const embed = await renderEmbed(video, org);
98+
expect(embed.props.defaultPlaybackSpeed).toBe(expected);
99+
});
100+
101+
it("uses the organization speed for minimal embeds", async () => {
102+
const embed = await renderEmbed(undefined, 1, true);
103+
expect(embed.props.minimal).toBe(true);
104+
expect(embed.props.defaultPlaybackSpeed).toBe(1);
105+
});
106+
});

apps/web/__tests__/unit/embed-video-playback-chrome.test.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,37 @@ vi.mock("@/app/s/[videoId]/_components/CapVideoPlayer", async () => {
8383
const { createElement, useEffect, useState } = await import("react");
8484
const DeferredVideoPlayer = ({
8585
videoRef,
86+
defaultPlaybackSpeed,
8687
}: {
8788
videoRef: Ref<HTMLVideoElement>;
89+
defaultPlaybackSpeed?: number;
8890
}) => {
8991
const [isMounted, setIsMounted] = useState(false);
9092
useEffect(() => setIsMounted(true), []);
91-
return isMounted ? createElement("video", { ref: videoRef }) : null;
93+
return isMounted
94+
? createElement("video", {
95+
ref: videoRef,
96+
"data-default-speed": defaultPlaybackSpeed,
97+
})
98+
: null;
9299
};
93100
return { CapVideoPlayer: DeferredVideoPlayer };
94101
});
95102

96103
vi.mock("@/app/s/[videoId]/_components/HLSVideoPlayer", async () => {
97104
const { createElement } = await import("react");
98105
return {
99-
HLSVideoPlayer: ({ videoRef }: { videoRef: Ref<HTMLVideoElement> }) =>
100-
createElement("video", { ref: videoRef }),
106+
HLSVideoPlayer: ({
107+
videoRef,
108+
defaultPlaybackSpeed,
109+
}: {
110+
videoRef: Ref<HTMLVideoElement>;
111+
defaultPlaybackSpeed?: number;
112+
}) =>
113+
createElement("video", {
114+
ref: videoRef,
115+
"data-default-speed": defaultPlaybackSpeed,
116+
}),
101117
};
102118
});
103119

@@ -173,6 +189,40 @@ describe("EmbedVideo playback chrome", () => {
173189
delete actEnvironment.IS_REACT_ACT_ENVIRONMENT;
174190
});
175191

192+
it.each(
193+
["desktopMP4", "webMP4", "MediaConvert", "desktopSegments"].flatMap(
194+
(type) =>
195+
[false, true].flatMap((minimal) =>
196+
[1, 1.5].map((speed) => ({ type, minimal, speed })),
197+
),
198+
),
199+
)(
200+
"passes $speed× to $type playback with minimal=$minimal",
201+
async ({ type, minimal, speed }) => {
202+
const container = document.createElement("div");
203+
document.body.append(container);
204+
const root = createRoot(container);
205+
const props = createProps({
206+
type,
207+
} as EmbedVideoProps["data"]["source"]);
208+
209+
await act(async () => {
210+
root.render(
211+
createElement(EmbedVideo, {
212+
...props,
213+
minimal,
214+
defaultPlaybackSpeed: speed,
215+
}),
216+
);
217+
});
218+
expect(container.querySelector("video")?.dataset.defaultSpeed).toBe(
219+
String(speed),
220+
);
221+
222+
await act(async () => root.unmount());
223+
},
224+
);
225+
176226
it.each([
177227
["an asynchronously mounted MP4", { type: "desktopMP4" } as const],
178228
["an HLS video", { type: "MediaConvert" } as const],

apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const EmbedVideo = forwardRef<
6464
/** Seconds to open at, from the embed URL's `?t=`. */
6565
startTime?: number | null;
6666
minimal?: boolean;
67+
defaultPlaybackSpeed?: number;
6768
viewerSettings?: ViewerSettings | null;
6869
showPlaybackStatusBadge?: boolean;
6970
}
@@ -79,6 +80,7 @@ export const EmbedVideo = forwardRef<
7980
autoplay = false,
8081
startTime = null,
8182
minimal = false,
83+
defaultPlaybackSpeed,
8284
viewerSettings,
8385
showPlaybackStatusBadge = false,
8486
},
@@ -274,6 +276,7 @@ export const EmbedVideo = forwardRef<
274276
captionsSrc={captionsDisabled ? "" : subtitleUrl || ""}
275277
videoRef={videoRef}
276278
autoplay={autoplay}
279+
defaultPlaybackSpeed={defaultPlaybackSpeed}
277280
enableCrossOrigin={enableCrossOrigin}
278281
hasActiveUpload={data.hasActiveUpload}
279282
/>
@@ -288,6 +291,7 @@ export const EmbedVideo = forwardRef<
288291
captionsSrc={captionsDisabled ? "" : subtitleUrl || ""}
289292
videoRef={videoRef}
290293
autoplay={autoplay}
294+
defaultPlaybackSpeed={defaultPlaybackSpeed}
291295
hasActiveUpload={data.hasActiveUpload}
292296
isLiveSegments={isSegmentsSource}
293297
/>

apps/web/app/embed/[videoId]/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { Effect, Option } from "effect";
2727
import type { Metadata } from "next";
2828
import Link from "next/link";
2929
import { notFound } from "next/navigation";
30+
import { resolveDefaultPlaybackSpeed } from "@/lib/playback-speed";
3031
import * as EffectRuntime from "@/lib/server";
3132
import { getSharePageBranding } from "@/lib/share-branding";
3233
import { buildShareVideoMetadata } from "@/lib/share-video-metadata";
@@ -392,6 +393,10 @@ async function EmbedContent({
392393
autoplay={autoplay}
393394
startTime={startTime}
394395
minimal={minimal}
396+
defaultPlaybackSpeed={resolveDefaultPlaybackSpeed(
397+
video.settings?.defaultPlaybackSpeed,
398+
video.orgSettings?.defaultPlaybackSpeed,
399+
)}
395400
viewerSettings={rules.settings}
396401
showPlaybackStatusBadge={user?.id === video.ownerId}
397402
/>

0 commit comments

Comments
 (0)