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 fac9bc0aa5c..0d3d00faa67 100644 --- a/apps/web/__tests__/unit/embed-video-playback-chrome.test.ts +++ b/apps/web/__tests__/unit/embed-video-playback-chrome.test.ts @@ -3,6 +3,7 @@ import { act, type ComponentProps, + type ComponentType, createElement, type MouseEventHandler, type ReactNode, @@ -24,6 +25,50 @@ import { type SharePageBrandingInput, } from "@/lib/share-branding"; +const dynamicImports = vi.hoisted(() => [] as Promise[]); +const dynamicControl = vi.hoisted(() => ({ + gate: null as Promise | null, + metadataReady: false, +})); + +vi.mock("next/dynamic", async () => { + const { createElement, useEffect, useState } = await import("react"); + return { + default: ( + loader: () => Promise< + ComponentType | { default: ComponentType } + >, + ) => { + let Loaded: ComponentType | null = null; + dynamicImports.push( + loader().then((module) => { + Loaded = "default" in module ? module.default : module; + }), + ); + return (props: object) => { + const [Player, setPlayer] = useState | 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 () => { @@ -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; @@ -79,6 +124,20 @@ vi.mock("@/app/embed/[videoId]/_components/use-player-js-receiver", () => ({ usePlayerJsReceiver: () => undefined, })); +function attachVideoRef( + ref: Ref, + 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 = ({ @@ -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; @@ -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, }), }; @@ -176,15 +237,74 @@ const expectChromeHidden = (container: HTMLElement) => { expect(container.querySelector('[aria-label="Powered by Cap"]')).toBeNull(); }; +beforeAll(async () => { + await Promise.all(dynamicImports); +}); + 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((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; }); diff --git a/apps/web/__tests__/unit/player-js-receiver-mount.test.ts b/apps/web/__tests__/unit/player-js-receiver-mount.test.ts new file mode 100644 index 00000000000..2a1b3900257 --- /dev/null +++ b/apps/web/__tests__/unit/player-js-receiver-mount.test.ts @@ -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(null); + const videoRef = useRef(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); + }, +); diff --git a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx index 07e8784b8d3..a7372945550 100644 --- a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx +++ b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx @@ -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, @@ -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; @@ -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( + trackUploadProgress ? { status: "fetching" } : null, + ); + useEffect(() => { + setSegmentUploadProgress( + trackUploadProgress ? { status: "fetching" } : null, + ); + }, [trackUploadProgress]); const [subtitleUrl, setSubtitleUrl] = useState(null); const [chaptersUrl, setChaptersUrl] = useState(null); const captionsDisabled = viewerSettings?.disableCaptions ?? false; @@ -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 @@ -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 ( <> @@ -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 ? ( )} + {trackUploadProgress && ( + + )} ); }, diff --git a/apps/web/app/embed/[videoId]/_components/use-player-js-receiver.ts b/apps/web/app/embed/[videoId]/_components/use-player-js-receiver.ts index 13e18b72afa..c3081de5269 100644 --- a/apps/web/app/embed/[videoId]/_components/use-player-js-receiver.ts +++ b/apps/web/app/embed/[videoId]/_components/use-player-js-receiver.ts @@ -258,9 +258,10 @@ export const usePlayerJsReceiver = ({ let currentVideo: HTMLVideoElement | null = null; let dispose: (() => void) | null = null; const bind = () => { - if (videoRef.current === currentVideo) return; + const video = videoRef.current ?? playerContainer.querySelector("video"); + if (video === currentVideo) return; dispose?.(); - currentVideo = videoRef.current; + currentVideo = video; dispose = currentVideo ? createPlayerJsReceiver({ playerWindow: window,