|
| 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