Skip to content
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.
Outdated
},
})
}
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
121 changes: 67 additions & 54 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,24 +60,30 @@ const MuteButton = styled(BaseButton)(({ theme }) => ({
},
}))

const CarouselSlide = styled.div<{ width: number }>(({ width, theme }) => ({
width,
overflow: "hidden",
borderRadius: "12px",
flex: "0 0 calc(100% - 60px)",
margin: "30px 0",
position: "relative",
[theme.breakpoints.down("md")]: {
width: "100%",
margin: "10px 0",
flex: "0 0 calc(100% - 20px)",
borderRadius: 0,
},
}))
const CarouselSlide = styled.div<{ width: number; height: number }>(
({ width, height, theme }) => ({
width,
height,
overflow: "hidden",
borderRadius: "12px",
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,
},
}),
)
Comment thread
daniellefrappier18 marked this conversation as resolved.
Outdated

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,8 +134,8 @@ const isIOS = () => {

type VideoWithErrorHandlerProps = {
index: number
video: VideoShort
videosRef: React.MutableRefObject<(HTMLVideoElement | null)[]>
video: VideoResource
videosRef: React.RefObject<(HTMLVideoElement | null)[]>
Comment thread
daniellefrappier18 marked this conversation as resolved.
Outdated
onError: (index: number, e: Event) => void
onVideoClick: () => void
videoWidth: number
Expand All @@ -145,6 +152,7 @@ const VideoWithErrorHandler = ({
videoHeight,
}: VideoWithErrorHandlerProps) => {
const handlerRef = useRef<((e: Event) => void) | null>(null)
const src = video.video?.streaming_url ?? undefined

const refCallback = useCallback(
(el: HTMLVideoElement | null) => {
Expand Down Expand Up @@ -172,7 +180,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 +197,7 @@ const VideoWithErrorHandler = ({

type VideoShortsModalProps = {
startIndex: number
videoData: VideoShort[]
videoData: VideoResource[]
onClose: () => void
}
const VideoShortsModal = ({
Expand Down Expand Up @@ -301,42 +309,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