Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

[Video] add scrubber to the web player (#4943)

authored by samuel.fm and committed by

GitHub 9b534b96 def9dda2

+392 -114
+5
bskyweb/templates/base.html
··· 253 253 from { opacity: 1; } 254 254 to { opacity: 0; } 255 255 } 256 + 257 + .force-no-clicks > *, 258 + .force-no-clicks * { 259 + pointer-events: none !important; 260 + } 256 261 </style> 257 262 </style> 258 263 {% include "scripts.html" %}
+2 -2
src/components/hooks/useInteractionState.ts
··· 5 5 6 6 const onIn = React.useCallback(() => { 7 7 setState(true) 8 - }, [setState]) 8 + }, []) 9 9 const onOut = React.useCallback(() => { 10 10 setState(false) 11 - }, [setState]) 11 + }, []) 12 12 13 13 return React.useMemo( 14 14 () => ({
+380 -112
src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
··· 6 6 useSyncExternalStore, 7 7 } from 'react' 8 8 import {Pressable, View} from 'react-native' 9 - import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 9 + import {SvgProps} from 'react-native-svg' 10 10 import {msg, Trans} from '@lingui/macro' 11 11 import {useLingui} from '@lingui/react' 12 12 import type Hls from 'hls.js' 13 13 14 - import {isIPhoneWeb} from 'platform/detection' 14 + import {isFirefox} from '#/lib/browser' 15 + import {clamp} from '#/lib/numbers' 16 + import {isIPhoneWeb} from '#/platform/detection' 15 17 import { 16 18 useAutoplayDisabled, 17 19 useSetSubtitlesEnabled, 18 20 useSubtitlesEnabled, 19 - } from 'state/preferences' 21 + } from '#/state/preferences' 20 22 import {atoms as a, useTheme, web} from '#/alf' 21 23 import {Button} from '#/components/Button' 22 24 import {useInteractionState} from '#/components/hooks/useInteractionState' ··· 173 175 toggleFullscreen() 174 176 }, [drawFocus, toggleFullscreen]) 175 177 178 + const onSeek = useCallback( 179 + (time: number) => { 180 + if (!videoRef.current) return 181 + if (videoRef.current.fastSeek) { 182 + videoRef.current.fastSeek(time) 183 + } else { 184 + videoRef.current.currentTime = time 185 + } 186 + }, 187 + [videoRef], 188 + ) 189 + 190 + const playStateBeforeSeekRef = useRef(false) 191 + 192 + const onSeekStart = useCallback(() => { 193 + drawFocus() 194 + playStateBeforeSeekRef.current = playing 195 + pause() 196 + }, [playing, pause, drawFocus]) 197 + 198 + const onSeekEnd = useCallback(() => { 199 + if (playStateBeforeSeekRef.current) { 200 + play() 201 + } 202 + }, [play]) 203 + 204 + const seekLeft = useCallback(() => { 205 + if (!videoRef.current) return 206 + // eslint-disable-next-line @typescript-eslint/no-shadow 207 + const currentTime = videoRef.current.currentTime 208 + // eslint-disable-next-line @typescript-eslint/no-shadow 209 + const duration = videoRef.current.duration || 0 210 + onSeek(clamp(currentTime - 5, 0, duration)) 211 + }, [onSeek, videoRef]) 212 + 213 + const seekRight = useCallback(() => { 214 + if (!videoRef.current) return 215 + // eslint-disable-next-line @typescript-eslint/no-shadow 216 + const currentTime = videoRef.current.currentTime 217 + // eslint-disable-next-line @typescript-eslint/no-shadow 218 + const duration = videoRef.current.duration || 0 219 + onSeek(clamp(currentTime + 5, 0, duration)) 220 + }, [onSeek, videoRef]) 221 + 176 222 const showControls = 177 223 (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) 178 224 ··· 197 243 <Pressable 198 244 accessibilityRole="button" 199 245 accessibilityHint={_( 200 - focused 246 + !focused 201 247 ? msg`Unmute video` 202 248 : playing 203 249 ? msg`Pause video` ··· 210 256 style={[ 211 257 a.flex_shrink_0, 212 258 a.w_full, 213 - a.px_sm, 214 - a.pt_sm, 215 - a.pb_md, 216 - a.gap_md, 217 - a.flex_row, 218 - a.align_center, 259 + a.px_xs, 219 260 web({ 220 261 background: 221 262 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', 222 263 }), 223 - showControls ? {opacity: 1} : {opacity: 0}, 264 + {opacity: showControls ? 1 : 0}, 265 + {transition: 'opacity 0.2s ease-in-out'}, 224 266 ]}> 225 - <Button 226 - label={_(playing ? msg`Pause` : msg`Play`)} 227 - onPress={onPressPlayPause} 228 - {...btnProps}> 229 - {playing ? ( 230 - <PauseIcon fill={t.palette.white} width={20} /> 231 - ) : ( 232 - <PlayIcon fill={t.palette.white} width={20} /> 233 - )} 234 - </Button> 235 - <View style={a.flex_1} /> 236 - <Text style={{color: t.palette.white}}> 237 - {formatTime(currentTime)} / {formatTime(duration)} 238 - </Text> 239 - {hasSubtitleTrack && ( 240 - <Button 241 - label={_( 242 - subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`, 243 - )} 244 - onPress={onPressSubtitles} 245 - {...btnProps}> 246 - {subtitlesEnabled ? ( 247 - <CCActiveIcon fill={t.palette.white} width={20} /> 248 - ) : ( 249 - <CCInactiveIcon fill={t.palette.white} width={20} /> 250 - )} 251 - </Button> 252 - )} 253 - <Button 254 - label={_(muted ? msg`Unmute` : msg`Mute`)} 255 - onPress={onPressMute} 256 - {...btnProps}> 257 - {muted ? ( 258 - <MuteIcon fill={t.palette.white} width={20} /> 259 - ) : ( 260 - <UnmuteIcon fill={t.palette.white} width={20} /> 261 - )} 262 - </Button> 263 - {!isIPhoneWeb && ( 264 - <Button 265 - label={_(muted ? msg`Unmute` : msg`Mute`)} 266 - onPress={onPressFullscreen} 267 - {...btnProps}> 268 - {isFullscreen ? ( 269 - <ArrowsInIcon fill={t.palette.white} width={20} /> 270 - ) : ( 271 - <ArrowsOutIcon fill={t.palette.white} width={20} /> 272 - )} 273 - </Button> 274 - )} 275 - </View> 276 - {(showControls || !focused) && ( 277 - <Animated.View 278 - entering={FadeIn.duration(200)} 279 - exiting={FadeOut.duration(200)} 267 + <Scrubber 268 + duration={duration} 269 + currentTime={currentTime} 270 + onSeek={onSeek} 271 + onSeekStart={onSeekStart} 272 + onSeekEnd={onSeekEnd} 273 + seekLeft={seekLeft} 274 + seekRight={seekRight} 275 + togglePlayPause={togglePlayPause} 276 + drawFocus={drawFocus} 277 + /> 278 + <View 280 279 style={[ 281 - a.absolute, 282 - { 283 - height: 5, 284 - bottom: 0, 285 - left: 0, 286 - right: 0, 287 - backgroundColor: 'rgba(255,255,255,0.4)', 288 - }, 280 + a.flex_1, 281 + a.px_xs, 282 + a.pt_sm, 283 + a.pb_md, 284 + a.gap_md, 285 + a.flex_row, 286 + a.align_center, 289 287 ]}> 290 - {duration > 0 && ( 291 - <View 292 - style={[ 293 - a.h_full, 294 - a.mr_auto, 295 - { 296 - backgroundColor: t.palette.white, 297 - width: `${(currentTime / duration) * 100}%`, 298 - opacity: 0.8, 299 - }, 300 - ]} 288 + <ControlButton 289 + active={playing} 290 + activeLabel={_(msg`Pause`)} 291 + inactiveLabel={_(msg`Play`)} 292 + activeIcon={PauseIcon} 293 + inactiveIcon={PlayIcon} 294 + onPress={onPressPlayPause} 295 + /> 296 + <View style={a.flex_1} /> 297 + <Text style={{color: t.palette.white}}> 298 + {formatTime(currentTime)} / {formatTime(duration)} 299 + </Text> 300 + {hasSubtitleTrack && ( 301 + <ControlButton 302 + active={subtitlesEnabled} 303 + activeLabel={_(msg`Disable subtitles`)} 304 + inactiveLabel={_(msg`Enable subtitles`)} 305 + activeIcon={CCActiveIcon} 306 + inactiveIcon={CCInactiveIcon} 307 + onPress={onPressSubtitles} 308 + /> 309 + )} 310 + <ControlButton 311 + active={muted} 312 + activeLabel={_(msg`Unmute`)} 313 + inactiveLabel={_(msg`Mute`)} 314 + activeIcon={MuteIcon} 315 + inactiveIcon={UnmuteIcon} 316 + onPress={onPressMute} 317 + /> 318 + {!isIPhoneWeb && ( 319 + <ControlButton 320 + active={isFullscreen} 321 + activeLabel={_(msg`Exit fullscreen`)} 322 + inactiveLabel={_(msg`Fullscreen`)} 323 + activeIcon={ArrowsInIcon} 324 + inactiveIcon={ArrowsOutIcon} 325 + onPress={onPressFullscreen} 301 326 /> 302 327 )} 303 - </Animated.View> 304 - )} 328 + </View> 329 + </View> 305 330 {(buffering || error) && ( 306 - <Animated.View 331 + <View 307 332 pointerEvents="none" 308 - entering={FadeIn.delay(1000).duration(200)} 309 - exiting={FadeOut.duration(200)} 310 333 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 311 334 {buffering && <Loader fill={t.palette.white} size="lg" />} 312 335 {error && ( ··· 314 337 <Trans>An error occurred</Trans> 315 338 </Text> 316 339 )} 317 - </Animated.View> 340 + </View> 318 341 )} 319 342 </div> 320 343 ) 321 344 } 322 345 323 - const btnProps = { 324 - variant: 'ghost', 325 - shape: 'round', 326 - size: 'medium', 327 - style: a.p_2xs, 328 - hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'}, 329 - } as const 346 + function ControlButton({ 347 + active, 348 + activeLabel, 349 + inactiveLabel, 350 + activeIcon: ActiveIcon, 351 + inactiveIcon: InactiveIcon, 352 + onPress, 353 + }: { 354 + active: boolean 355 + activeLabel: string 356 + inactiveLabel: string 357 + activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> 358 + inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> 359 + onPress: () => void 360 + }) { 361 + const t = useTheme() 362 + return ( 363 + <Button 364 + label={active ? activeLabel : inactiveLabel} 365 + onPress={onPress} 366 + variant="ghost" 367 + shape="round" 368 + size="medium" 369 + style={a.p_2xs} 370 + hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}> 371 + {active ? ( 372 + <ActiveIcon fill={t.palette.white} width={20} /> 373 + ) : ( 374 + <InactiveIcon fill={t.palette.white} width={20} /> 375 + )} 376 + </Button> 377 + ) 378 + } 379 + 380 + function Scrubber({ 381 + duration, 382 + currentTime, 383 + onSeek, 384 + onSeekEnd, 385 + onSeekStart, 386 + seekLeft, 387 + seekRight, 388 + togglePlayPause, 389 + drawFocus, 390 + }: { 391 + duration: number 392 + currentTime: number 393 + onSeek: (time: number) => void 394 + onSeekEnd: () => void 395 + onSeekStart: () => void 396 + seekLeft: () => void 397 + seekRight: () => void 398 + togglePlayPause: () => void 399 + drawFocus: () => void 400 + }) { 401 + const {_} = useLingui() 402 + const t = useTheme() 403 + const [scrubberActive, setScrubberActive] = useState(false) 404 + const { 405 + state: hovered, 406 + onIn: onMouseEnter, 407 + onOut: onMouseLeave, 408 + } = useInteractionState() 409 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 410 + const [seekPosition, setSeekPosition] = useState(0) 411 + const isSeekingRef = useRef(false) 412 + const barRef = useRef<HTMLDivElement>(null) 413 + const circleRef = useRef<HTMLDivElement>(null) 414 + 415 + const seek = useCallback( 416 + (evt: React.PointerEvent<HTMLDivElement>) => { 417 + if (!barRef.current) return 418 + const {left, width} = barRef.current.getBoundingClientRect() 419 + const x = evt.clientX 420 + const percent = clamp((x - left) / width, 0, 1) * duration 421 + onSeek(percent) 422 + setSeekPosition(percent) 423 + }, 424 + [duration, onSeek], 425 + ) 426 + 427 + const onPointerDown = useCallback( 428 + (evt: React.PointerEvent<HTMLDivElement>) => { 429 + const target = evt.target 430 + if (target instanceof Element) { 431 + evt.preventDefault() 432 + target.setPointerCapture(evt.pointerId) 433 + isSeekingRef.current = true 434 + seek(evt) 435 + setScrubberActive(true) 436 + onSeekStart() 437 + } 438 + }, 439 + [seek, onSeekStart], 440 + ) 441 + 442 + const onPointerMove = useCallback( 443 + (evt: React.PointerEvent<HTMLDivElement>) => { 444 + if (isSeekingRef.current) { 445 + evt.preventDefault() 446 + seek(evt) 447 + } 448 + }, 449 + [seek], 450 + ) 451 + 452 + const onPointerUp = useCallback( 453 + (evt: React.PointerEvent<HTMLDivElement>) => { 454 + const target = evt.target 455 + if (isSeekingRef.current && target instanceof Element) { 456 + evt.preventDefault() 457 + target.releasePointerCapture(evt.pointerId) 458 + isSeekingRef.current = false 459 + onSeekEnd() 460 + setScrubberActive(false) 461 + } 462 + }, 463 + [onSeekEnd], 464 + ) 465 + 466 + useEffect(() => { 467 + // HACK: there's divergent browser behaviour about what to do when 468 + // a pointerUp event is fired outside the element that captured the 469 + // pointer. Firefox clicks on the element the mouse is over, so we have 470 + // to make everything unclickable while seeking -sfn 471 + if (isFirefox && scrubberActive) { 472 + document.body.classList.add('force-no-clicks') 473 + 474 + const abortController = new AbortController() 475 + const {signal} = abortController 476 + document.documentElement.addEventListener( 477 + 'mouseleave', 478 + () => { 479 + isSeekingRef.current = false 480 + onSeekEnd() 481 + setScrubberActive(false) 482 + }, 483 + {signal}, 484 + ) 485 + 486 + return () => { 487 + document.body.classList.remove('force-no-clicks') 488 + abortController.abort() 489 + } 490 + } 491 + }, [scrubberActive, onSeekEnd]) 492 + 493 + useEffect(() => { 494 + if (!circleRef.current) return 495 + if (focused) { 496 + const abortController = new AbortController() 497 + const {signal} = abortController 498 + circleRef.current.addEventListener( 499 + 'keydown', 500 + evt => { 501 + // space: play/pause 502 + // arrow left: seek backward 503 + // arrow right: seek forward 504 + 505 + if (evt.key === ' ') { 506 + evt.preventDefault() 507 + drawFocus() 508 + togglePlayPause() 509 + } else if (evt.key === 'ArrowLeft') { 510 + evt.preventDefault() 511 + drawFocus() 512 + seekLeft() 513 + } else if (evt.key === 'ArrowRight') { 514 + evt.preventDefault() 515 + drawFocus() 516 + seekRight() 517 + } 518 + }, 519 + {signal}, 520 + ) 521 + 522 + return () => abortController.abort() 523 + } 524 + }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) 525 + 526 + const progress = scrubberActive ? seekPosition : currentTime 527 + const progressPercent = (progress / duration) * 100 528 + 529 + return ( 530 + <View 531 + testID="scrubber" 532 + style={[{height: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]} 533 + // @ts-expect-error web only -sfn 534 + onMouseEnter={onMouseEnter} 535 + onMouseLeave={onMouseLeave}> 536 + <div 537 + ref={barRef} 538 + style={{ 539 + flex: 1, 540 + display: 'flex', 541 + alignItems: 'center', 542 + position: 'relative', 543 + cursor: scrubberActive ? 'grabbing' : 'grab', 544 + }} 545 + onPointerDown={onPointerDown} 546 + onPointerMove={onPointerMove} 547 + onPointerUp={onPointerUp}> 548 + <View 549 + style={[ 550 + a.w_full, 551 + a.rounded_full, 552 + a.overflow_hidden, 553 + {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, 554 + {height: hovered || scrubberActive ? 6 : 3}, 555 + ]}> 556 + {currentTime && duration && ( 557 + <View 558 + style={[ 559 + a.h_full, 560 + {backgroundColor: t.palette.white}, 561 + {width: `${progressPercent}%`}, 562 + ]} 563 + /> 564 + )} 565 + </View> 566 + <div 567 + ref={circleRef} 568 + aria-label={_(msg`Seek slider`)} 569 + role="slider" 570 + aria-valuemax={duration} 571 + aria-valuemin={0} 572 + aria-valuenow={currentTime} 573 + aria-valuetext={_( 574 + msg`${formatTime(currentTime)} of ${formatTime(duration)}`, 575 + )} 576 + tabIndex={0} 577 + onFocus={onFocus} 578 + onBlur={onBlur} 579 + style={{ 580 + position: 'absolute', 581 + height: 16, 582 + width: 16, 583 + left: `calc(${progressPercent}% - 8px)`, 584 + borderRadius: 8, 585 + pointerEvents: 'none', 586 + }}> 587 + <View 588 + style={[ 589 + a.w_full, 590 + a.h_full, 591 + a.rounded_full, 592 + {backgroundColor: t.palette.white}, 593 + { 594 + transform: [ 595 + { 596 + scale: 597 + hovered || scrubberActive || focused 598 + ? scrubberActive 599 + ? 1 600 + : 0.6 601 + : 0, 602 + }, 603 + ], 604 + }, 605 + ]} 606 + /> 607 + </div> 608 + </div> 609 + </View> 610 + ) 611 + } 330 612 331 613 function formatTime(time: number) { 332 614 if (isNaN(time)) { ··· 421 703 setError(false) 422 704 } 423 705 424 - const handleSeeking = () => { 425 - setBuffering(true) 426 - } 427 - 428 - const handleSeeked = () => { 429 - setBuffering(false) 430 - } 431 - 432 706 const handleStalled = () => { 433 707 if (bufferingTimeout) clearTimeout(bufferingTimeout) 434 708 bufferingTimeout = setTimeout(() => { ··· 472 746 signal: abortController.signal, 473 747 }) 474 748 ref.current.addEventListener('playing', handlePlaying, { 475 - signal: abortController.signal, 476 - }) 477 - ref.current.addEventListener('seeking', handleSeeking, { 478 - signal: abortController.signal, 479 - }) 480 - ref.current.addEventListener('seeked', handleSeeked, { 481 749 signal: abortController.signal, 482 750 }) 483 751 ref.current.addEventListener('stalled', handleStalled, {
+5
web/index.html
··· 257 257 from { opacity: 1; } 258 258 to { opacity: 0; } 259 259 } 260 + 261 + .force-no-clicks > *, 262 + .force-no-clicks * { 263 + pointer-events: none !important; 264 + } 260 265 </style> 261 266 </head> 262 267