Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 123 additions & 3 deletions apps/web/__tests__/unit/embed-video-playback-chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
act,
type ComponentProps,
type ComponentType,
createElement,
type MouseEventHandler,
type ReactNode,
Expand All @@ -24,6 +25,50 @@ import {
type SharePageBrandingInput,
} from "@/lib/share-branding";

const dynamicImports = vi.hoisted(() => [] as Promise<unknown>[]);
const dynamicControl = vi.hoisted(() => ({
gate: null as Promise<void> | null,
metadataReady: false,
}));

vi.mock("next/dynamic", async () => {
const { createElement, useEffect, useState } = await import("react");
return {
default: (
loader: () => Promise<
ComponentType<object> | { default: ComponentType<object> }
>,
) => {
let Loaded: ComponentType<object> | null = null;
dynamicImports.push(
loader().then((module) => {
Loaded = "default" in module ? module.default : module;
}),
);
return (props: object) => {
const [Player, setPlayer] = useState<ComponentType<object> | null>(
() => (dynamicControl.gate ? null : Loaded),
);
useEffect(() => {
let cancelled = false;
const pendingLoad = async () => {
await dynamicControl.gate;
const module = await loader();
if (!cancelled) {
setPlayer(() => ("default" in module ? module.default : module));
}
};
void pendingLoad();
return () => {
cancelled = true;
};
}, []);
return Player ? createElement(Player, props) : null;
};
},
};
});

vi.mock("@cap/env", () => ({ NODE_ENV: "test" }));

vi.mock("@cap/ui", async () => {
Expand All @@ -34,7 +79,7 @@ vi.mock("@cap/ui", async () => {
};
});

vi.mock("framer-motion", async () => {
vi.mock("motion/react", async () => {
const { createElement } = await import("react");
type MotionProps = {
children?: ReactNode;
Expand Down Expand Up @@ -79,6 +124,20 @@ vi.mock("@/app/embed/[videoId]/_components/use-player-js-receiver", () => ({
usePlayerJsReceiver: () => undefined,
}));

function attachVideoRef(
ref: Ref<HTMLVideoElement>,
video: HTMLVideoElement | null,
) {
if (video && dynamicControl.metadataReady) {
Object.defineProperties(video, {
duration: { configurable: true, value: 60 },
readyState: { configurable: true, value: 1 },
});
}
if (typeof ref === "function") return ref(video);
if (ref) ref.current = video;
}

vi.mock("@/app/s/[videoId]/_components/CapVideoPlayer", async () => {
const { createElement, useEffect, useState } = await import("react");
const DeferredVideoPlayer = ({
Expand All @@ -92,7 +151,8 @@ vi.mock("@/app/s/[videoId]/_components/CapVideoPlayer", async () => {
useEffect(() => setIsMounted(true), []);
return isMounted
? createElement("video", {
ref: videoRef,
ref: (video: HTMLVideoElement | null) =>
attachVideoRef(videoRef, video),
"data-default-speed": defaultPlaybackSpeed,
})
: null;
Expand All @@ -111,7 +171,8 @@ vi.mock("@/app/s/[videoId]/_components/HLSVideoPlayer", async () => {
defaultPlaybackSpeed?: number;
}) =>
createElement("video", {
ref: videoRef,
ref: (video: HTMLVideoElement | null) =>
attachVideoRef(videoRef, video),
"data-default-speed": defaultPlaybackSpeed,
}),
};
Expand Down Expand Up @@ -176,15 +237,74 @@ const expectChromeHidden = (container: HTMLElement) => {
expect(container.querySelector('[aria-label="Powered by Cap"]')).toBeNull();
};

beforeAll(async () => {
await Promise.all(dynamicImports);
});
Comment on lines +240 to +242

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Late loading goes untested

The new next/dynamic mock starts every loader immediately, and beforeAll waits for all of them before rendering. This skips the loading behavior introduced by this PR, so the tests cannot catch problems when a player arrives after EmbedVideo mounts. Keep a loader pending during at least one test, resolve it after rendering, and check that playback events still update the chrome.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/unit/embed-video-playback-chrome.test.ts
Line: 201-203

Comment:
**Late loading goes untested**

The new `next/dynamic` mock starts every loader immediately, and `beforeAll` waits for all of them before rendering. This skips the loading behavior introduced by this PR, so the tests cannot catch problems when a player arrives after `EmbedVideo` mounts. Keep a loader pending during at least one test, resolve it after rendering, and check that playback events still update the chrome.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


describe("EmbedVideo playback chrome", () => {
beforeAll(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
});

afterEach(() => {
dynamicControl.gate = null;
dynamicControl.metadataReady = false;
document.body.replaceChildren();
});

it.each(
[
{ type: "desktopMP4" } as const,
{ type: "MediaConvert" } as const,
].flatMap((source) =>
[false, true].map((metadataReady) => ({ source, metadataReady })),
),
)(
"seeks and updates chrome when the $source.type player loads after mount with metadataReady=$metadataReady",
async ({ source, metadataReady }) => {
dynamicControl.metadataReady = metadataReady;
let release = () => {};
dynamicControl.gate = new Promise<void>((resolve) => {
release = resolve;
});
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(EmbedVideo, { ...createProps(source), startTime: 15 }),
);
});
expect(container.querySelector("video")).toBeNull();
expectChromeVisible(container);
await act(async () => {
release();
await dynamicControl.gate;
});
const video = container.querySelector("video");
if (!video) throw new Error("Player did not load");
if (!metadataReady) {
Object.defineProperty(video, "duration", { value: 60 });
await act(async () => {
video.dispatchEvent(new Event("loadedmetadata"));
});
}
expect(video.currentTime).toBe(15);
await act(async () => {
video.dispatchEvent(new Event("play"));
});
expectChromeHidden(container);
video.currentTime = 25;
await act(async () => {
video.dispatchEvent(new Event("pause"));
video.dispatchEvent(new Event("loadedmetadata"));
});
expectChromeVisible(container);
expect(video.currentTime).toBe(25);
await act(async () => root.unmount());
},
);

afterAll(() => {
delete actEnvironment.IS_REACT_ACT_ENVIRONMENT;
});
Expand Down
60 changes: 60 additions & 0 deletions apps/web/__tests__/unit/player-js-receiver-mount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment jsdom

import { act, createElement, useRef } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { usePlayerJsReceiver } from "@/app/embed/[videoId]/_components/use-player-js-receiver";

it.each([false, true])(
"binds and cleans up Player.js with video ref attached=%s",
async (attachRef) => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const postMessage = vi
.spyOn(window.parent, "postMessage")
.mockImplementation(() => {});
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
function Player() {
const playerContainerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
usePlayerJsReceiver({ playerContainerRef, videoRef });
return createElement(
"div",
{ ref: playerContainerRef },
createElement("video", { ref: attachRef ? videoRef : undefined }),
);
}
const seek = (value: number) =>
window.dispatchEvent(
new MessageEvent("message", {
source: window.parent,
origin: window.location.origin,
data: JSON.stringify({
context: "player.js",
method: "setCurrentTime",
value,
}),
}),
);

let video: HTMLVideoElement | null = null;
try {
await act(async () => root.render(createElement(Player)));
video = container.querySelector("video");
if (!video) throw new Error("Missing video element");
expect(postMessage).toHaveBeenCalledOnce();
expect(JSON.parse(String(postMessage.mock.calls[0]?.[0]))).toMatchObject({
event: "ready",
});
seek(15);
expect(video.currentTime).toBe(15);
} finally {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
}
seek(25);
expect(video?.currentTime).toBe(15);
},
);
82 changes: 60 additions & 22 deletions apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,23 @@ import type { comments as commentsSchema, videos } from "@cap/database/schema";
import { NODE_ENV } from "@cap/env";
import { Avatar, Logo } from "@cap/ui";
import type { ViewerSettings } from "@cap/web-backend";
import { AnimatePresence, motion } from "framer-motion";
import { useTranscript } from "hooks/use-transcript";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import Image from "next/image";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { CapVideoPlayer } from "@/app/s/[videoId]/_components/CapVideoPlayer";
import { HLSVideoPlayer } from "@/app/s/[videoId]/_components/HLSVideoPlayer";
import { useUploadProgress } from "@/app/s/[videoId]/_components/ProgressCircle";
import {
PreparingVideoOverlay,
RecordingInProgressOverlay,
} from "@/app/s/[videoId]/_components/RecordingInProgress";
import type { UploadProgress } from "@/app/s/[videoId]/_components/upload-progress";
import {
formatChaptersAsVTT,
formatTranscriptAsVTT,
Expand All @@ -33,6 +33,21 @@ import type { SharePageBranding } from "@/lib/share-branding";
import type { ShareCallToAction } from "@/lib/share-call-to-action";
import { usePlayerJsReceiver } from "./use-player-js-receiver";

const CapVideoPlayer = dynamic(() =>
import("@/app/s/[videoId]/_components/CapVideoPlayer").then(
(m) => m.CapVideoPlayer,
),
);
const UploadProgressTracker = dynamic(
() => import("@/app/s/[videoId]/_components/UploadProgressTracker"),
{ ssr: false },
);
const HLSVideoPlayer = dynamic(() =>
import("@/app/s/[videoId]/_components/HLSVideoPlayer").then(
(m) => m.HLSVideoPlayer,
),
);

declare global {
interface Window {
MSStream: unknown;
Expand Down Expand Up @@ -96,10 +111,17 @@ export const EmbedVideo = forwardRef<
);
const [isPlaying, setIsPlaying] = useState(false);
const [userConfirmedStopped, setUserConfirmedStopped] = useState(false);
const segmentUploadProgress = useUploadProgress(
data.id,
data.source.type === "desktopSegments" && (data.hasActiveUpload ?? false),
);
const trackUploadProgress =
data.source.type === "desktopSegments" && (data.hasActiveUpload ?? false);
const [segmentUploadProgress, setSegmentUploadProgress] =
useState<UploadProgress | null>(
trackUploadProgress ? { status: "fetching" } : null,
);
useEffect(() => {
setSegmentUploadProgress(
trackUploadProgress ? { status: "fetching" } : null,
);
}, [trackUploadProgress]);
const [subtitleUrl, setSubtitleUrl] = useState<string | null>(null);
const [chaptersUrl, setChaptersUrl] = useState<string | null>(null);
const captionsDisabled = viewerSettings?.disableCaptions ?? false;
Expand Down Expand Up @@ -213,10 +235,8 @@ export const EmbedVideo = forwardRef<
videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=video`;
}

useEffect(() => {
if (!videoRef.current) return;
const player = videoRef.current;
const handleLoadedMetadata = () => {
const handleLoadedMetadata = useCallback(
(player: HTMLVideoElement) => {
setLongestDuration(player.duration);

// Once only. HLS level switches and source swaps fire this again, and
Expand All @@ -232,18 +252,25 @@ export const EmbedVideo = forwardRef<
} catch (error) {
console.warn("Failed to seek embed to start time", error);
}
};

if (player.readyState >= 1) {
handleLoadedMetadata();
} else {
player.addEventListener("loadedmetadata", handleLoadedMetadata);
}
},
[startTime],
);

return () => {
player.removeEventListener("loadedmetadata", handleLoadedMetadata);
useEffect(() => {
const container = playerContainerRef.current;
if (!container) return;
let currentPlayer: HTMLVideoElement | null = null;
const syncMetadata = () => {
const player = container.querySelector("video");
if (player === currentPlayer) return;
currentPlayer = player;
if (player && player.readyState >= 1) handleLoadedMetadata(player);
};
}, [startTime]);
const observer = new MutationObserver(syncMetadata);
observer.observe(container, { childList: true, subtree: true });
syncMetadata();
return () => observer.disconnect();
}, [handleLoadedMetadata]);

return (
<>
Expand All @@ -253,6 +280,11 @@ export const EmbedVideo = forwardRef<
onPlayCapture={() => setIsPlaying(true)}
onPauseCapture={() => setIsPlaying(false)}
onEndedCapture={() => setIsPlaying(false)}
onLoadedMetadataCapture={(event) => {
if (event.target instanceof HTMLVideoElement) {
handleLoadedMetadata(event.target);
}
}}
>
{isActivelyRecording ? (
<RecordingInProgressOverlay
Expand Down Expand Up @@ -388,6 +420,12 @@ export const EmbedVideo = forwardRef<
)}
</AnimatePresence>
)}
{trackUploadProgress && (
<UploadProgressTracker
videoId={data.id}
onChange={setSegmentUploadProgress}
/>
)}
</>
);
},
Expand Down
Loading