Skip to content

Commit 86b33d4

Browse files
authored
Video Shorts / Media Section (#2388)
* Pull shorts from youtube and display in new section carousel * Homepage video section and initial player modal * Vertical carousel for video player * Mock data. Experiments with IntersectionObserver for play/pause. Mobile styles wip * Fixes gesture navigation, scroll snap and interception observer * Refactor horizontal carousel to ol-component anbd reuse on resource carousels * Refactor horizontal carousel to ol-component anbd reuse on resource carousels * Dynamic load media section to avoid SSR hydration error due to client Posthog flag * Unknown values * Generic for useThrottle * Margin fixes * Mute for autoplay handling (iOS restrictions) * Remove video controls and provide an mute/unmute button * Fix circ dependency * Mock IntersectionObserver and faeture flag (test fixes) * Add direct exports for CarouselV2 components to avoid circular dependency with @mitodl/smoot-design imports * Permissions policy not used
1 parent 8e8cccd commit 86b33d4

File tree

20 files changed

+1801
-211
lines changed

20 files changed

+1801
-211
lines changed

frontends/api/src/hooks/videoShorts/index.ts

Lines changed: 728 additions & 0 deletions
Large diffs are not rendered by default.

frontends/jest-shared-setup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ Object.defineProperty(window, "matchMedia", {
3333

3434
Element.prototype.scrollIntoView = jest.fn()
3535

36+
window.IntersectionObserver = jest.fn().mockImplementation(() => ({
37+
observe: () => null,
38+
unobserve: () => null,
39+
disconnect: () => null,
40+
}))
41+
3642
/*
3743
* This used to live in ol-ckeditor but we also need it now for NukaCarousel,
3844
* so it's now here so it's available across the board.

frontends/main/src/app-pages/HomePage/HomePage.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import {
1717
import invariant from "tiny-invariant"
1818
import * as routes from "@/common/urls"
1919
import { assertHeadings } from "ol-test-utilities"
20+
import { useFeatureFlagEnabled } from "posthog-js/react"
21+
22+
jest.mock("posthog-js/react")
23+
const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled)
2024

2125
const assertLinksTo = (
2226
el: HTMLElement,
@@ -78,6 +82,8 @@ const setupAPIs = () => {
7882
expect.stringContaining(urls.testimonials.list({})),
7983
attestations,
8084
)
85+
86+
mockedUseFeatureFlagEnabled.mockReturnValue(false)
8187
}
8288

8389
describe("Home Page Hero", () => {

frontends/main/src/app-pages/HomePage/HomePage.tsx

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,6 @@ const FeaturedCoursesCarousel = styled(ResourceCarousel)(({ theme }) => ({
2929
},
3030
}))
3131

32-
const MediaCarousel = styled(ResourceCarousel)(({ theme }) => ({
33-
margin: "80px 0",
34-
minHeight: "388px",
35-
[theme.breakpoints.down("md")]: {
36-
margin: "40px 0",
37-
minHeight: "418px",
38-
},
39-
}))
40-
4132
const StyledContainer = styled(Container)({
4233
"@media (max-width: 1365px)": {
4334
overflow: "hidden",
@@ -49,6 +40,8 @@ const LearningResourceDrawer = dynamic(
4940
import("@/page-components/LearningResourceDrawer/LearningResourceDrawer"),
5041
)
5142

43+
const MediaSection = dynamic(() => import("./MediaSection"))
44+
5245
const HomePage: React.FC<{ heroImageIndex: number }> = ({ heroImageIndex }) => {
5346
return (
5447
<>
@@ -66,13 +59,7 @@ const HomePage: React.FC<{ heroImageIndex: number }> = ({ heroImageIndex }) => {
6659
</StyledContainer>
6760
</FullWidthBackground>
6861
<PersonalizeSection />
69-
<Container component="section">
70-
<MediaCarousel
71-
titleComponent="h2"
72-
title="Media"
73-
config={carousels.MEDIA_CAROUSEL}
74-
/>
75-
</Container>
62+
<MediaSection />
7663
<BrowseTopicsSection />
7764
<TestimonialsSection />
7865
<NewsEventsSection />
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React from "react"
2+
import { Container, styled } from "ol-components"
3+
import VideoShortsSection from "./VideoShortsSection"
4+
import { FeatureFlags } from "@/common/feature_flags"
5+
import { useFeatureFlagEnabled } from "posthog-js/react"
6+
import ResourceCarousel from "@/page-components/ResourceCarousel/ResourceCarousel"
7+
import * as carousels from "./carousels"
8+
9+
const MediaCarousel = styled(ResourceCarousel)(({ theme }) => ({
10+
margin: "80px 0",
11+
minHeight: "388px",
12+
[theme.breakpoints.down("md")]: {
13+
margin: "40px 0",
14+
minHeight: "418px",
15+
},
16+
}))
17+
18+
const MediaSection = () => {
19+
const videoShortsEnabled = useFeatureFlagEnabled(FeatureFlags.VideoShorts)
20+
21+
if (videoShortsEnabled === undefined) {
22+
return null
23+
}
24+
if (videoShortsEnabled) {
25+
return <VideoShortsSection />
26+
}
27+
return (
28+
<Container component="section">
29+
<MediaCarousel
30+
titleComponent="h2"
31+
title="Media"
32+
config={carousels.MEDIA_CAROUSEL}
33+
/>
34+
</Container>
35+
)
36+
}
37+
38+
export default MediaSection
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import React, { useEffect, useRef, useState } from "react"
2+
import { styled } from "ol-components"
3+
import { CarouselV2Vertical } from "ol-components/CarouselV2Vertical"
4+
import { RiCloseLine, RiVolumeMuteLine, RiVolumeUpLine } from "@remixicon/react"
5+
import { ActionButton } from "@mitodl/smoot-design"
6+
import { useWindowDimensions } from "ol-utilities"
7+
import type { VideoShort } from "api/hooks/videoShorts"
8+
9+
const Overlay = styled.div(({ theme }) => ({
10+
position: "fixed",
11+
top: 0,
12+
left: 0,
13+
width: "100%",
14+
height: "100%",
15+
backgroundColor: "rgba(0, 0, 0, 0.9)",
16+
zIndex: 1200,
17+
[theme.breakpoints.down("md")]: {
18+
backgroundColor: theme.custom.colors.black,
19+
},
20+
}))
21+
22+
const CloseButton = styled(ActionButton)({
23+
position: "absolute",
24+
top: "16px",
25+
right: "16px",
26+
zIndex: 1201,
27+
svg: {
28+
fill: "white",
29+
},
30+
})
31+
32+
const MuteButton = styled(ActionButton)({
33+
position: "absolute",
34+
right: "16px",
35+
bottom: "16px",
36+
zIndex: 1201,
37+
svg: {
38+
fill: "white",
39+
},
40+
})
41+
42+
const CarouselSlide = styled.div<{ width: number }>(({ width, theme }) => ({
43+
width,
44+
overflow: "hidden",
45+
borderRadius: "12px",
46+
flex: "0 0 calc(100% - 60px)",
47+
margin: "30px 0",
48+
position: "relative",
49+
[theme.breakpoints.down("md")]: {
50+
width: "100%",
51+
margin: "10px 0",
52+
flex: "0 0 calc(100% - 20px)",
53+
borderRadius: 0,
54+
},
55+
}))
56+
57+
const Placeholder = styled.div(({ theme }) => ({
58+
width: "100%",
59+
height: "100%",
60+
backgroundColor: "black",
61+
borderRadius: "12px",
62+
[theme.breakpoints.down("md")]: {
63+
borderRadius: 0,
64+
},
65+
}))
66+
67+
const Video = styled.video(({ height, width, theme }) => ({
68+
width,
69+
height,
70+
[theme.breakpoints.down("md")]: {
71+
width: "100%",
72+
height: "100%",
73+
},
74+
}))
75+
76+
const isPlaying = (videoElement: HTMLVideoElement | null): boolean => {
77+
if (!videoElement) return false
78+
79+
const isPlaying =
80+
!videoElement.paused && !videoElement.ended && videoElement.currentTime > 0
81+
82+
const isReady = videoElement.readyState >= 2
83+
84+
const hasDuration = videoElement.duration > 0
85+
86+
return isPlaying && isReady && hasDuration
87+
}
88+
89+
const isIOS = () => {
90+
return /iPad|iPhone|iPod/.test(navigator.userAgent)
91+
}
92+
93+
type VideoShortsModalProps = {
94+
startIndex: number
95+
videoData: VideoShort[]
96+
onClose: () => void
97+
}
98+
const VideoShortsModal = ({
99+
startIndex = 0,
100+
videoData,
101+
onClose,
102+
}: VideoShortsModalProps) => {
103+
const { height } = useWindowDimensions()
104+
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
105+
const [muted, setMuted] = useState(true)
106+
const [hasUserInteracted, setHasUserInteracted] = useState(false)
107+
108+
const videosRef = useRef<(HTMLVideoElement | null)[]>([])
109+
110+
useEffect(() => {
111+
videosRef.current = videosRef.current.slice(0, videoData.length)
112+
}, [videoData])
113+
114+
useEffect(() => {
115+
setSelectedIndex(startIndex)
116+
}, [startIndex])
117+
118+
useEffect(() => {
119+
const handleKeyDown = (event: KeyboardEvent) => {
120+
if (event.key === "Escape" && onClose) {
121+
onClose()
122+
}
123+
124+
if (event.key === "Space" && selectedIndex !== null) {
125+
if (isPlaying(videosRef.current[selectedIndex])) {
126+
videosRef.current[selectedIndex]?.pause()
127+
} else {
128+
videosRef.current[selectedIndex]?.play().catch(() => {})
129+
}
130+
}
131+
132+
event.preventDefault()
133+
}
134+
135+
document.addEventListener("keydown", handleKeyDown)
136+
return () => {
137+
document.removeEventListener("keydown", handleKeyDown)
138+
}
139+
}, [onClose, selectedIndex])
140+
141+
const onSlidesInView = (inView: number[]) => {
142+
if (inView.length === 1) {
143+
videosRef.current
144+
.filter((video, index) => video && index !== inView[0])
145+
.forEach((video) => {
146+
video!.pause()
147+
})
148+
setSelectedIndex(inView[0])
149+
if (videosRef.current[inView[0]]) {
150+
const video = videosRef.current[inView[0]]!
151+
video.muted = muted
152+
153+
// On iOS, only autoplay if muted or if user has interacted
154+
if (!isIOS() || muted || hasUserInteracted) {
155+
video.play().catch(() => {})
156+
}
157+
}
158+
}
159+
}
160+
161+
const onClickMute = () => {
162+
if (selectedIndex !== null && videosRef.current[selectedIndex]) {
163+
const video = videosRef.current[selectedIndex]!
164+
const wasMuted = video.muted
165+
video.muted = !wasMuted
166+
167+
setHasUserInteracted(true)
168+
169+
if (wasMuted && !video.paused) {
170+
video.play().catch(() => {})
171+
}
172+
}
173+
setMuted(!muted)
174+
}
175+
176+
const handleVideoClick = () => {
177+
if (selectedIndex !== null && videosRef.current[selectedIndex]) {
178+
const video = videosRef.current[selectedIndex]!
179+
setHasUserInteracted(true)
180+
181+
if (video.paused) {
182+
video.play().catch(() => {})
183+
} else {
184+
video.pause()
185+
}
186+
}
187+
}
188+
189+
return (
190+
<Overlay>
191+
<CloseButton size="large" edge="rounded" variant="text" onClick={onClose}>
192+
<RiCloseLine />
193+
</CloseButton>
194+
<MuteButton
195+
size="large"
196+
edge="rounded"
197+
variant="text"
198+
onClick={onClickMute}
199+
>
200+
{muted ? <RiVolumeMuteLine /> : <RiVolumeUpLine />}
201+
</MuteButton>
202+
<CarouselV2Vertical
203+
initialSlide={startIndex}
204+
onSlidesInView={onSlidesInView}
205+
>
206+
{videoData?.map((item: VideoShort, index: number) => (
207+
<CarouselSlide
208+
key={index}
209+
width={(height - 60) * (9 / 16)}
210+
data-index={index}
211+
>
212+
{selectedIndex !== null && Math.abs(selectedIndex - index) < 2 ? (
213+
<Video
214+
ref={(el) => {
215+
if (videosRef.current && el) {
216+
videosRef.current[index] = el
217+
el.addEventListener("error", (e: Event) => {
218+
console.error("Video error:", e)
219+
})
220+
}
221+
}}
222+
onClick={handleVideoClick}
223+
// TODO: Using a temporary bucket on GCP owned by jk
224+
src={`https://storage.googleapis.com/mit-open-learning/${item.id.videoId}.mp4`}
225+
autoPlay
226+
muted
227+
playsInline
228+
webkit-playsinline="true"
229+
controlsList="nofullscreen"
230+
disablePictureInPicture
231+
width={(height - 60) * (9 / 16)}
232+
height={height - 60}
233+
preload="metadata"
234+
loop
235+
/>
236+
) : (
237+
<Placeholder />
238+
)}
239+
</CarouselSlide>
240+
))}
241+
</CarouselV2Vertical>
242+
</Overlay>
243+
)
244+
}
245+
246+
export default VideoShortsModal

0 commit comments

Comments
 (0)