An ATproto social media client -- with an independent Appview.
at main 429 lines 13 kB view raw
1import {useCallback, useEffect, useRef, useState} from 'react' 2import {Pressable, View} from 'react-native' 3import {msg, Trans} from '@lingui/macro' 4import {useLingui} from '@lingui/react' 5import type Hls from 'hls.js' 6 7import {isTouchDevice} from '#/lib/browser' 8import {clamp} from '#/lib/numbers' 9import {isIPhoneWeb} from '#/platform/detection' 10import { 11 useAutoplayDisabled, 12 useSetSubtitlesEnabled, 13 useSubtitlesEnabled, 14} from '#/state/preferences' 15import {atoms as a, useTheme, web} from '#/alf' 16import {useIsWithinMessage} from '#/components/dms/MessageContext' 17import {useFullscreen} from '#/components/hooks/useFullscreen' 18import {useInteractionState} from '#/components/hooks/useInteractionState' 19import { 20 ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, 21 ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, 22} from '#/components/icons/ArrowsDiagonal' 23import { 24 CC_Filled_Corner0_Rounded as CCActiveIcon, 25 CC_Stroke2_Corner0_Rounded as CCInactiveIcon, 26} from '#/components/icons/CC' 27import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' 28import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 29import {Loader} from '#/components/Loader' 30import {Text} from '#/components/Typography' 31import {TimeIndicator} from '../TimeIndicator' 32import {ControlButton} from './ControlButton' 33import {Scrubber} from './Scrubber' 34import {formatTime, useVideoElement} from './utils' 35import {VolumeControl} from './VolumeControl' 36 37export function Controls({ 38 videoRef, 39 hlsRef, 40 active, 41 setActive, 42 focused, 43 setFocused, 44 onScreen, 45 fullscreenRef, 46 hlsLoading, 47 hasSubtitleTrack, 48}: { 49 videoRef: React.RefObject<HTMLVideoElement | null> 50 hlsRef: React.RefObject<Hls | undefined | null> 51 active: boolean 52 setActive: () => void 53 focused: boolean 54 setFocused: (focused: boolean) => void 55 onScreen: boolean 56 fullscreenRef: React.RefObject<HTMLDivElement | null> 57 hlsLoading: boolean 58 hasSubtitleTrack: boolean 59}) { 60 const { 61 play, 62 pause, 63 playing, 64 muted, 65 changeMuted, 66 togglePlayPause, 67 currentTime, 68 duration, 69 buffering, 70 error, 71 canPlay, 72 } = useVideoElement(videoRef) 73 const t = useTheme() 74 const {_} = useLingui() 75 const subtitlesEnabled = useSubtitlesEnabled() 76 const setSubtitlesEnabled = useSetSubtitlesEnabled() 77 const { 78 state: hovered, 79 onIn: onHover, 80 onOut: onEndHover, 81 } = useInteractionState() 82 const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) 83 const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() 84 const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) 85 const showSpinner = hlsLoading || buffering 86 const { 87 state: volumeHovered, 88 onIn: onVolumeHover, 89 onOut: onVolumeEndHover, 90 } = useInteractionState() 91 92 const onKeyDown = useCallback(() => { 93 setInteractingViaKeypress(true) 94 }, []) 95 96 useEffect(() => { 97 if (interactingViaKeypress) { 98 document.addEventListener('click', () => setInteractingViaKeypress(false)) 99 return () => { 100 document.removeEventListener('click', () => 101 setInteractingViaKeypress(false), 102 ) 103 } 104 } 105 }, [interactingViaKeypress]) 106 107 useEffect(() => { 108 if (isFullscreen) { 109 document.documentElement.style.scrollbarGutter = 'unset' 110 return () => { 111 document.documentElement.style.removeProperty('scrollbar-gutter') 112 } 113 } 114 }, [isFullscreen]) 115 116 // pause + unfocus when another video is active 117 useEffect(() => { 118 if (!active) { 119 pause() 120 setFocused(false) 121 } 122 }, [active, pause, setFocused]) 123 124 // autoplay/pause based on visibility 125 const isWithinMessage = useIsWithinMessage() 126 const autoplayDisabled = useAutoplayDisabled() || isWithinMessage 127 useEffect(() => { 128 if (active) { 129 if (onScreen) { 130 if (!autoplayDisabled) play() 131 } else { 132 pause() 133 } 134 } 135 }, [onScreen, pause, active, play, autoplayDisabled]) 136 137 // use minimal quality when not focused 138 useEffect(() => { 139 if (!hlsRef.current) return 140 if (focused) { 141 // allow 30s of buffering 142 hlsRef.current.config.maxMaxBufferLength = 30 143 } else { 144 // back to what we initially set 145 hlsRef.current.config.maxMaxBufferLength = 10 146 } 147 }, [hlsRef, focused]) 148 149 useEffect(() => { 150 if (!hlsRef.current) return 151 if (hasSubtitleTrack && subtitlesEnabled && canPlay) { 152 hlsRef.current.subtitleTrack = 0 153 } else { 154 hlsRef.current.subtitleTrack = -1 155 } 156 }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) 157 158 // clicking on any button should focus the player, if it's not already focused 159 const drawFocus = useCallback(() => { 160 if (!active) { 161 setActive() 162 } 163 setFocused(true) 164 }, [active, setActive, setFocused]) 165 166 const onPressEmptySpace = useCallback(() => { 167 if (!focused) { 168 drawFocus() 169 if (autoplayDisabled) play() 170 } else { 171 togglePlayPause() 172 } 173 }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) 174 175 const onPressPlayPause = useCallback(() => { 176 drawFocus() 177 togglePlayPause() 178 }, [drawFocus, togglePlayPause]) 179 180 const onPressSubtitles = useCallback(() => { 181 drawFocus() 182 setSubtitlesEnabled(!subtitlesEnabled) 183 }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) 184 185 const onPressFullscreen = useCallback(() => { 186 drawFocus() 187 toggleFullscreen() 188 }, [drawFocus, toggleFullscreen]) 189 190 const onSeek = useCallback( 191 (time: number) => { 192 if (!videoRef.current) return 193 if (videoRef.current.fastSeek) { 194 videoRef.current.fastSeek(time) 195 } else { 196 videoRef.current.currentTime = time 197 } 198 }, 199 [videoRef], 200 ) 201 202 const playStateBeforeSeekRef = useRef(false) 203 204 const onSeekStart = useCallback(() => { 205 drawFocus() 206 playStateBeforeSeekRef.current = playing 207 pause() 208 }, [playing, pause, drawFocus]) 209 210 const onSeekEnd = useCallback(() => { 211 if (playStateBeforeSeekRef.current) { 212 play() 213 } 214 }, [play]) 215 216 const seekLeft = useCallback(() => { 217 if (!videoRef.current) return 218 // eslint-disable-next-line @typescript-eslint/no-shadow 219 const currentTime = videoRef.current.currentTime 220 // eslint-disable-next-line @typescript-eslint/no-shadow 221 const duration = videoRef.current.duration || 0 222 onSeek(clamp(currentTime - 5, 0, duration)) 223 }, [onSeek, videoRef]) 224 225 const seekRight = useCallback(() => { 226 if (!videoRef.current) return 227 // eslint-disable-next-line @typescript-eslint/no-shadow 228 const currentTime = videoRef.current.currentTime 229 // eslint-disable-next-line @typescript-eslint/no-shadow 230 const duration = videoRef.current.duration || 0 231 onSeek(clamp(currentTime + 5, 0, duration)) 232 }, [onSeek, videoRef]) 233 234 const [showCursor, setShowCursor] = useState(true) 235 const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined) 236 const onPointerMoveEmptySpace = useCallback(() => { 237 setShowCursor(true) 238 if (cursorTimeoutRef.current) { 239 clearTimeout(cursorTimeoutRef.current) 240 } 241 cursorTimeoutRef.current = setTimeout(() => { 242 setShowCursor(false) 243 onEndHover() 244 }, 2000) 245 }, [onEndHover]) 246 const onPointerLeaveEmptySpace = useCallback(() => { 247 setShowCursor(false) 248 if (cursorTimeoutRef.current) { 249 clearTimeout(cursorTimeoutRef.current) 250 } 251 }, []) 252 253 // these are used to trigger the hover state. on mobile, the hover state 254 // should stick around for a bit after they tap, and if the controls aren't 255 // present this initial tab should *only* show the controls and not activate anything 256 257 const onPointerDown = useCallback( 258 (evt: React.PointerEvent<HTMLDivElement>) => { 259 if (evt.pointerType !== 'mouse' && !hovered) { 260 evt.preventDefault() 261 } 262 clearTimeout(timeoutRef.current) 263 }, 264 [hovered], 265 ) 266 267 const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined) 268 269 const onHoverWithTimeout = useCallback(() => { 270 onHover() 271 clearTimeout(timeoutRef.current) 272 }, [onHover]) 273 274 const onEndHoverWithTimeout = useCallback( 275 (evt: React.PointerEvent<HTMLDivElement>) => { 276 // if touch, end after 3s 277 // if mouse, end immediately 278 if (evt.pointerType !== 'mouse') { 279 setTimeout(onEndHover, 3000) 280 } else { 281 onEndHover() 282 } 283 }, 284 [onEndHover], 285 ) 286 287 const showControls = 288 ((focused || autoplayDisabled) && !playing) || 289 (interactingViaKeypress ? hasFocus : hovered) 290 291 return ( 292 <div 293 style={{ 294 position: 'absolute', 295 inset: 0, 296 overflow: 'hidden', 297 display: 'flex', 298 flexDirection: 'column', 299 }} 300 onClick={evt => { 301 evt.stopPropagation() 302 setInteractingViaKeypress(false) 303 }} 304 onPointerEnter={onHoverWithTimeout} 305 onPointerMove={onHoverWithTimeout} 306 onPointerLeave={onEndHoverWithTimeout} 307 onPointerDown={onPointerDown} 308 onFocus={onFocus} 309 onBlur={onBlur} 310 onKeyDown={onKeyDown}> 311 <Pressable 312 accessibilityRole="button" 313 onPointerEnter={onPointerMoveEmptySpace} 314 onPointerMove={onPointerMoveEmptySpace} 315 onPointerLeave={onPointerLeaveEmptySpace} 316 accessibilityLabel={_( 317 !focused 318 ? msg`Unmute video` 319 : playing 320 ? msg`Pause video` 321 : msg`Play video`, 322 )} 323 accessibilityHint="" 324 style={[ 325 a.flex_1, 326 web({cursor: showCursor || !playing ? 'pointer' : 'none'}), 327 ]} 328 onPress={onPressEmptySpace} 329 /> 330 {!showControls && !focused && duration > 0 && ( 331 <TimeIndicator time={Math.floor(duration - currentTime)} /> 332 )} 333 <View 334 style={[ 335 a.flex_shrink_0, 336 a.w_full, 337 a.px_xs, 338 web({ 339 background: 340 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', 341 }), 342 {opacity: showControls ? 1 : 0}, 343 {transition: 'opacity 0.2s ease-in-out'}, 344 ]}> 345 {(!volumeHovered || isTouchDevice) && ( 346 <Scrubber 347 duration={duration} 348 currentTime={currentTime} 349 onSeek={onSeek} 350 onSeekStart={onSeekStart} 351 onSeekEnd={onSeekEnd} 352 seekLeft={seekLeft} 353 seekRight={seekRight} 354 togglePlayPause={togglePlayPause} 355 drawFocus={drawFocus} 356 /> 357 )} 358 <View 359 style={[ 360 a.flex_1, 361 a.px_xs, 362 a.pb_sm, 363 a.gap_sm, 364 a.flex_row, 365 a.align_center, 366 ]}> 367 <ControlButton 368 active={playing} 369 activeLabel={_(msg`Pause`)} 370 inactiveLabel={_(msg`Play`)} 371 activeIcon={PauseIcon} 372 inactiveIcon={PlayIcon} 373 onPress={onPressPlayPause} 374 /> 375 <View style={a.flex_1} /> 376 {Math.round(duration) > 0 && ( 377 <Text 378 style={[ 379 a.px_xs, 380 {color: t.palette.white, fontVariant: ['tabular-nums']}, 381 ]}> 382 {formatTime(currentTime)} / {formatTime(duration)} 383 </Text> 384 )} 385 {hasSubtitleTrack && ( 386 <ControlButton 387 active={subtitlesEnabled} 388 activeLabel={_(msg`Disable subtitles`)} 389 inactiveLabel={_(msg`Enable subtitles`)} 390 activeIcon={CCActiveIcon} 391 inactiveIcon={CCInactiveIcon} 392 onPress={onPressSubtitles} 393 /> 394 )} 395 <VolumeControl 396 muted={muted} 397 changeMuted={changeMuted} 398 hovered={volumeHovered} 399 onHover={onVolumeHover} 400 onEndHover={onVolumeEndHover} 401 drawFocus={drawFocus} 402 /> 403 {!isIPhoneWeb && ( 404 <ControlButton 405 active={isFullscreen} 406 activeLabel={_(msg`Exit fullscreen`)} 407 inactiveLabel={_(msg`Enter fullscreen`)} 408 activeIcon={ArrowsInIcon} 409 inactiveIcon={ArrowsOutIcon} 410 onPress={onPressFullscreen} 411 /> 412 )} 413 </View> 414 </View> 415 {(showSpinner || error) && ( 416 <View 417 pointerEvents="none" 418 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 419 {showSpinner && <Loader fill={t.palette.white} size="lg" />} 420 {error && ( 421 <Text style={{color: t.palette.white}}> 422 <Trans>An error occurred</Trans> 423 </Text> 424 )} 425 </View> 426 )} 427 </div> 428 ) 429}