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
19 changes: 18 additions & 1 deletion frontends/api/src/hooks/videoShorts/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"
import { videoShortsApi } from "../../clients"
import { videoShortsApi, learningResourcesSearchApi } from "../../clients"
import type { VideoResource } from "../../generated/v1"

export const useVideoShortsList = () => {
return useQuery({
Expand All @@ -12,3 +13,19 @@ export const useVideoShortsList = () => {
},
})
}

export const useVideoShortsLearningResources = () => {
return useQuery({
queryKey: ["video_shorts", "learning_resources"],
queryFn: async () => {
const { data } =
await learningResourcesSearchApi.learningResourcesSearchRetrieve({
resource_category: ["Video Short"],
limit: 50,
})
return data.results.filter(
(r): r is VideoResource => r.resource_type === "video",
)
Comment thread
daniellefrappier18 marked this conversation as resolved.
},
})
}
109 changes: 45 additions & 64 deletions frontends/main/src/app-pages/HomePage/VideoShortsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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> = {},
): 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(),
}
Expand Down Expand Up @@ -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(<VideoShortsModal {...defaultProps} />)

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(
<VideoShortsModal
Expand All @@ -101,17 +89,13 @@ describe("VideoShortsModal", () => {
/>,
)

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(
<VideoShortsModal
Expand All @@ -122,21 +106,21 @@ describe("VideoShortsModal", () => {
)

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(
Expand All @@ -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)
})
Expand Down
101 changes: 60 additions & 41 deletions frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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%",
Expand Down Expand Up @@ -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
Expand All @@ -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) => {
Expand Down Expand Up @@ -172,7 +186,7 @@ const VideoWithErrorHandler = ({
<Video
ref={refCallback}
onClick={onVideoClick}
src={`${NEXT_PUBLIC_ORIGIN}${video.video_url}`}
src={src}
autoPlay
muted
playsInline
Comment thread
daniellefrappier18 marked this conversation as resolved.
Expand All @@ -189,7 +203,7 @@ const VideoWithErrorHandler = ({

type VideoShortsModalProps = {
startIndex: number
videoData: VideoShort[]
videoData: VideoResource[]
onClose: () => void
}
const VideoShortsModal = ({
Expand Down Expand Up @@ -301,42 +315,47 @@ const VideoShortsModal = ({
initialSlide={startIndex}
onSlidesInView={onSlidesInView}
>
{videoData?.map((video: VideoShort, index: number) => (
<CarouselSlide
key={index}
width={(height - 60) * (9 / 16)}
data-index={index}
>
{selectedIndex !== null && Math.abs(selectedIndex - index) < 2 ? (
videoErrors[index] ? (
<Placeholder>
{/* eslint-disable-next-line @next/next/no-img-element */}
<Image
src={MITOpenLearningLogo.src}
alt="MIT Open Learning Logo"
width={178}
height={47}
style={{ filter: "brightness(0) invert(1)" }}
{videoData?.map((video: VideoResource, index: number) => {
const videoHeight = Math.max(height - MODAL_VIDEO_CHROME_OFFSET, 0)

return (
<CarouselSlide
key={video.id}
width={videoHeight * PORTRAIT_ASPECT_RATIO}
height={videoHeight}
data-index={index}
>
{selectedIndex !== null && Math.abs(selectedIndex - index) < 2 ? (
videoErrors[index] ? (
<Placeholder>
{/* eslint-disable-next-line @next/next/no-img-element */}
<Image
src={MITOpenLearningLogo.src}
alt="MIT Open Learning Logo"
width={178}
height={47}
style={{ filter: "brightness(0) invert(1)" }}
/>
<Typography variant="h4">Playback errored!</Typography>
<Typography variant="h2">{video.title}</Typography>
</Placeholder>
) : (
<VideoWithErrorHandler
index={index}
video={video}
videosRef={videosRef}
onError={onVideoError}
onVideoClick={handleVideoClick}
videoWidth={videoHeight * PORTRAIT_ASPECT_RATIO}
videoHeight={videoHeight}
/>
<Typography variant="h4">Playback errored!</Typography>
<Typography variant="h2">{video.title}</Typography>
</Placeholder>
)
) : (
<VideoWithErrorHandler
index={index}
video={video}
videosRef={videosRef}
onError={onVideoError}
onVideoClick={handleVideoClick}
videoWidth={(height - 60) * (9 / 16)}
videoHeight={height - 60}
/>
)
) : (
<Placeholder />
)}
</CarouselSlide>
))}
<Placeholder />
)}
</CarouselSlide>
)
})}
</CarouselV2Vertical>
</Overlay>
)
Expand Down
Loading
Loading