diff --git a/frontends/api/src/hooks/videoShorts/index.ts b/frontends/api/src/hooks/videoShorts/index.ts deleted file mode 100644 index 890fb048eb..0000000000 --- a/frontends/api/src/hooks/videoShorts/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useQuery } from "@tanstack/react-query" -import { videoShortsApi } from "../../clients" - -export const useVideoShortsList = () => { - return useQuery({ - queryKey: ["video_shorts", "list"], - queryFn: async () => { - const { data } = await videoShortsApi.videoShortsList({ - limit: 50, - }) - return data.results - }, - }) -} diff --git a/frontends/main/src/app-pages/HomePage/VideoShortsModal.test.tsx b/frontends/main/src/app-pages/HomePage/VideoShortsModal.test.tsx index 1215b2b8a6..0a5b165ab8 100644 --- a/frontends/main/src/app-pages/HomePage/VideoShortsModal.test.tsx +++ b/frontends/main/src/app-pages/HomePage/VideoShortsModal.test.tsx @@ -1,45 +1,35 @@ import React from "react" import VideoShortsModal from "./VideoShortsModal" -import type { VideoShort } from "api/v0" -import { renderWithProviders, screen, user, fireEvent } from "@/test-utils" - -jest.mock("ol-utilities", () => ({ - ...jest.requireActual("ol-utilities"), - useWindowDimensions: () => ({ height: 800, width: 1024 }), -})) - -const originalEnv = process.env - -// NEXT_PUBLIC_ORIGIN is read at module load; Jest typically sets this for tests -const TEST_ORIGIN = - process.env.NEXT_PUBLIC_ORIGIN ?? "http://test.learn.odl.local:8062" - -const createMockVideoShort = ( - overrides: Partial = {}, -): VideoShort => ({ - video_id: "test-video-id", - title: "Test Video Title", - video_url: "/media/shorts/test.mp4", - published_at: "2024-01-01T00:00:00Z", - created_on: "2024-01-01T00:00:00Z", - updated_on: "2024-01-01T00:00:00Z", - ...overrides, -}) +import type { VideoResource } from "api/v1" +import { ResourceTypeEnum } from "api/v1" +import { factories } from "api/test-utils" +import { renderWithProviders, screen, user, fireEvent, act } from "@/test-utils" + +// JSDOM does not implement HTMLMediaElement methods +window.HTMLMediaElement.prototype.play = jest.fn(() => Promise.resolve()) +window.HTMLMediaElement.prototype.pause = jest.fn() + +const makeVideoResource = ( + overrides: Partial = {}, +): VideoResource => + factories.learningResources.video({ + resource_type: ResourceTypeEnum.Video, + video: { + id: 1, + streaming_url: "https://example.com/video.mp4", + duration: "PT1M", + caption_urls: [], + cover_image_url: null, + }, + ...overrides, + }) as VideoResource describe("VideoShortsModal", () => { - beforeEach(() => { - jest.resetModules() - }) - - afterEach(() => { - process.env = originalEnv - }) - const defaultProps = { startIndex: 0, videoData: [ - createMockVideoShort({ title: "First Video" }), - createMockVideoShort({ title: "Second Video", video_id: "vid-2" }), + makeVideoResource({ title: "First Video" }), + makeVideoResource({ title: "Second Video" }), ], onClose: jest.fn(), } @@ -76,22 +66,20 @@ describe("VideoShortsModal", () => { const buttons = screen.getAllByRole("button") const muteButton = buttons[1] await user.click(muteButton) - - // After click, mute button should show volume up icon (unmuted state) - // Click again to toggle back await user.click(muteButton) - // Both clicks should be handled without error expect(buttons[1]).toBeInTheDocument() }) - test("renders video with /media prefix unchanged", () => { - const videoData = [ - createMockVideoShort({ - video_url: "/media/shorts/existing.mp4", - title: "Existing Prefix Video", - }), - ] + test("renders native video elements for nearby slides", () => { + renderWithProviders() + + const videos = document.querySelectorAll("video") + expect(videos.length).toBeGreaterThanOrEqual(1) + }) + + test("video element has correct src from streaming_url", () => { + const videoData = [makeVideoResource({ title: "Test Video" })] renderWithProviders( { />, ) - const videos = document.querySelectorAll("video") - expect(videos[0]).toHaveAttribute( - "src", - `${TEST_ORIGIN}/media/shorts/existing.mp4`, - ) + const video = document.querySelector("video") + expect(video?.src).toBe("https://example.com/video.mp4") }) test("displays error placeholder when video errors", async () => { - const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation() - - const videoData = [createMockVideoShort({ title: "Error Video" })] + const videoData = [makeVideoResource({ title: "Error Video" })] + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}) renderWithProviders( { ) const video = document.querySelector("video") - expect(video).toBeInTheDocument() - fireEvent.error(video!) + await act(async () => { + if (video) fireEvent.error(video) + }) expect(screen.getByText("Playback errored!")).toBeInTheDocument() expect(screen.getByText("Error Video")).toBeInTheDocument() - - consoleErrorSpy.mockRestore() + consoleSpy.mockRestore() }) test("renders with startIndex to show correct initial video", () => { const videoData = [ - createMockVideoShort({ title: "First", video_id: "1" }), - createMockVideoShort({ title: "Second", video_id: "2" }), - createMockVideoShort({ title: "Third", video_id: "3" }), + makeVideoResource({ title: "First" }), + makeVideoResource({ title: "Second" }), + makeVideoResource({ title: "Third" }), ] renderWithProviders( @@ -147,9 +131,6 @@ describe("VideoShortsModal", () => { />, ) - // With startIndex=1, selectedIndex starts at 1 - // Videos at index 0, 1, 2 are within Math.abs(selectedIndex - index) < 2 - // So we should see videos for indices 0, 1, 2 const videos = document.querySelectorAll("video") expect(videos.length).toBeGreaterThanOrEqual(1) }) diff --git a/frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx b/frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx index f8f3437a51..edcfbc4dcd 100644 --- a/frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx +++ b/frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx @@ -5,10 +5,11 @@ import { CarouselV2Vertical } from "ol-components/CarouselV2Vertical" import { RiCloseLine, RiVolumeMuteLine, RiVolumeUpLine } from "@remixicon/react" import { ActionButton } from "@mitodl/smoot-design" import { useWindowDimensions } from "ol-utilities" -import type { VideoShort } from "api/v0" +import type { VideoResource } from "api/v1" import MITOpenLearningLogo from "@/public/images/mit-open-learning-logo.svg" -const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN +const MODAL_VIDEO_CHROME_OFFSET = 60 +const PORTRAIT_ASPECT_RATIO = 9 / 16 const Overlay = styled.div(({ theme }) => ({ position: "fixed", @@ -59,15 +60,19 @@ const MuteButton = styled(BaseButton)(({ theme }) => ({ }, })) -const CarouselSlide = styled.div<{ width: number }>(({ width, theme }) => ({ +const CarouselSlide = styled("div", { + shouldForwardProp: (prop) => prop !== "width" && prop !== "height", +})<{ width: number; height: number }>(({ width, height, theme }) => ({ width, + height, overflow: "hidden", borderRadius: "12px", - flex: "0 0 calc(100% - 60px)", + flex: "0 0 auto", margin: "30px 0", position: "relative", [theme.breakpoints.down("md")]: { width: "100%", + height: "100%", margin: "10px 0", flex: "0 0 calc(100% - 20px)", borderRadius: 0, @@ -77,6 +82,8 @@ const CarouselSlide = styled.div<{ width: number }>(({ width, theme }) => ({ const Video = styled.video(({ height, width, theme }) => ({ width, height, + objectFit: "cover", + display: "block", backgroundColor: theme.custom.colors.black, [theme.breakpoints.down("md")]: { width: "100%", @@ -127,7 +134,7 @@ const isIOS = () => { type VideoWithErrorHandlerProps = { index: number - video: VideoShort + video: VideoResource videosRef: React.MutableRefObject<(HTMLVideoElement | null)[]> onError: (index: number, e: Event) => void onVideoClick: () => void @@ -145,6 +152,13 @@ const VideoWithErrorHandler = ({ videoHeight, }: VideoWithErrorHandlerProps) => { const handlerRef = useRef<((e: Event) => void) | null>(null) + const src = video.video?.streaming_url ?? undefined + + useEffect(() => { + if (!src) { + onError(index, new Event("missingsource")) + } + }, [src, index, onError]) const refCallback = useCallback( (el: HTMLVideoElement | null) => { @@ -172,7 +186,7 @@ const VideoWithErrorHandler = ({