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