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