Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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