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