mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at verify-code 874 lines 24 kB view raw
1import React, { 2 useCallback, 3 useEffect, 4 useRef, 5 useState, 6 useSyncExternalStore, 7} from 'react' 8import {Pressable, View} from 'react-native' 9import {SvgProps} from 'react-native-svg' 10import {msg, Trans} from '@lingui/macro' 11import {useLingui} from '@lingui/react' 12import type Hls from 'hls.js' 13 14import {isFirefox} from '#/lib/browser' 15import {clamp} from '#/lib/numbers' 16import {isIPhoneWeb} from '#/platform/detection' 17import { 18 useAutoplayDisabled, 19 useSetSubtitlesEnabled, 20 useSubtitlesEnabled, 21} from '#/state/preferences' 22import {atoms as a, useTheme, web} from '#/alf' 23import {Button} from '#/components/Button' 24import {useInteractionState} from '#/components/hooks/useInteractionState' 25import { 26 ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, 27 ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, 28} from '#/components/icons/ArrowsDiagonal' 29import { 30 CC_Filled_Corner0_Rounded as CCActiveIcon, 31 CC_Stroke2_Corner0_Rounded as CCInactiveIcon, 32} from '#/components/icons/CC' 33import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 34import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' 35import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 36import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 37import {Loader} from '#/components/Loader' 38import {Text} from '#/components/Typography' 39import {TimeIndicator} from './TimeIndicator' 40 41export function Controls({ 42 videoRef, 43 hlsRef, 44 active, 45 setActive, 46 focused, 47 setFocused, 48 onScreen, 49 fullscreenRef, 50 hasSubtitleTrack, 51}: { 52 videoRef: React.RefObject<HTMLVideoElement> 53 hlsRef: React.RefObject<Hls | undefined> 54 active: boolean 55 setActive: () => void 56 focused: boolean 57 setFocused: (focused: boolean) => void 58 onScreen: boolean 59 fullscreenRef: React.RefObject<HTMLDivElement> 60 hasSubtitleTrack: boolean 61}) { 62 const { 63 play, 64 pause, 65 playing, 66 muted, 67 toggleMute, 68 togglePlayPause, 69 currentTime, 70 duration, 71 buffering, 72 error, 73 canPlay, 74 } = useVideoUtils(videoRef) 75 const t = useTheme() 76 const {_} = useLingui() 77 const subtitlesEnabled = useSubtitlesEnabled() 78 const setSubtitlesEnabled = useSetSubtitlesEnabled() 79 const { 80 state: hovered, 81 onIn: onHover, 82 onOut: onEndHover, 83 } = useInteractionState() 84 const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) 85 const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() 86 const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) 87 88 const onKeyDown = useCallback(() => { 89 setInteractingViaKeypress(true) 90 }, []) 91 92 useEffect(() => { 93 if (interactingViaKeypress) { 94 document.addEventListener('click', () => setInteractingViaKeypress(false)) 95 return () => { 96 document.removeEventListener('click', () => 97 setInteractingViaKeypress(false), 98 ) 99 } 100 } 101 }, [interactingViaKeypress]) 102 103 // pause + unfocus when another video is active 104 useEffect(() => { 105 if (!active) { 106 pause() 107 setFocused(false) 108 } 109 }, [active, pause, setFocused]) 110 111 // autoplay/pause based on visibility 112 const autoplayDisabled = useAutoplayDisabled() 113 useEffect(() => { 114 if (active) { 115 if (onScreen) { 116 if (!autoplayDisabled) play() 117 } else { 118 pause() 119 } 120 } 121 }, [onScreen, pause, active, play, autoplayDisabled]) 122 123 // use minimal quality when not focused 124 useEffect(() => { 125 if (!hlsRef.current) return 126 if (focused) { 127 // auto decide quality based on network conditions 128 hlsRef.current.autoLevelCapping = -1 129 } else { 130 hlsRef.current.autoLevelCapping = 0 131 } 132 }, [hlsRef, focused]) 133 134 useEffect(() => { 135 if (!hlsRef.current) return 136 if (hasSubtitleTrack && subtitlesEnabled && canPlay) { 137 hlsRef.current.subtitleTrack = 0 138 } else { 139 hlsRef.current.subtitleTrack = -1 140 } 141 }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) 142 143 // clicking on any button should focus the player, if it's not already focused 144 const drawFocus = useCallback(() => { 145 if (!active) { 146 setActive() 147 } 148 setFocused(true) 149 }, [active, setActive, setFocused]) 150 151 const onPressEmptySpace = useCallback(() => { 152 if (!focused) { 153 drawFocus() 154 if (autoplayDisabled) play() 155 } else { 156 togglePlayPause() 157 } 158 }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) 159 160 const onPressPlayPause = useCallback(() => { 161 drawFocus() 162 togglePlayPause() 163 }, [drawFocus, togglePlayPause]) 164 165 const onPressSubtitles = useCallback(() => { 166 drawFocus() 167 setSubtitlesEnabled(!subtitlesEnabled) 168 }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) 169 170 const onPressMute = useCallback(() => { 171 drawFocus() 172 toggleMute() 173 }, [drawFocus, toggleMute]) 174 175 const onPressFullscreen = useCallback(() => { 176 drawFocus() 177 toggleFullscreen() 178 }, [drawFocus, toggleFullscreen]) 179 180 const onSeek = useCallback( 181 (time: number) => { 182 if (!videoRef.current) return 183 if (videoRef.current.fastSeek) { 184 videoRef.current.fastSeek(time) 185 } else { 186 videoRef.current.currentTime = time 187 } 188 }, 189 [videoRef], 190 ) 191 192 const playStateBeforeSeekRef = useRef(false) 193 194 const onSeekStart = useCallback(() => { 195 drawFocus() 196 playStateBeforeSeekRef.current = playing 197 pause() 198 }, [playing, pause, drawFocus]) 199 200 const onSeekEnd = useCallback(() => { 201 if (playStateBeforeSeekRef.current) { 202 play() 203 } 204 }, [play]) 205 206 const seekLeft = useCallback(() => { 207 if (!videoRef.current) return 208 // eslint-disable-next-line @typescript-eslint/no-shadow 209 const currentTime = videoRef.current.currentTime 210 // eslint-disable-next-line @typescript-eslint/no-shadow 211 const duration = videoRef.current.duration || 0 212 onSeek(clamp(currentTime - 5, 0, duration)) 213 }, [onSeek, videoRef]) 214 215 const seekRight = useCallback(() => { 216 if (!videoRef.current) return 217 // eslint-disable-next-line @typescript-eslint/no-shadow 218 const currentTime = videoRef.current.currentTime 219 // eslint-disable-next-line @typescript-eslint/no-shadow 220 const duration = videoRef.current.duration || 0 221 onSeek(clamp(currentTime + 5, 0, duration)) 222 }, [onSeek, videoRef]) 223 224 const [showCursor, setShowCursor] = useState(true) 225 const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>() 226 const onPointerMoveEmptySpace = useCallback(() => { 227 setShowCursor(true) 228 if (cursorTimeoutRef.current) { 229 clearTimeout(cursorTimeoutRef.current) 230 } 231 cursorTimeoutRef.current = setTimeout(() => { 232 setShowCursor(false) 233 onEndHover() 234 }, 2000) 235 }, [onEndHover]) 236 const onPointerLeaveEmptySpace = useCallback(() => { 237 setShowCursor(false) 238 if (cursorTimeoutRef.current) { 239 clearTimeout(cursorTimeoutRef.current) 240 } 241 }, []) 242 243 const showControls = 244 ((focused || autoplayDisabled) && !playing) || 245 (interactingViaKeypress ? hasFocus : hovered) 246 247 return ( 248 <div 249 style={{ 250 position: 'absolute', 251 inset: 0, 252 overflow: 'hidden', 253 display: 'flex', 254 flexDirection: 'column', 255 }} 256 onClick={evt => { 257 evt.stopPropagation() 258 setInteractingViaKeypress(false) 259 }} 260 onPointerEnter={onHover} 261 onPointerMove={onHover} 262 onPointerLeave={onEndHover} 263 onFocus={onFocus} 264 onBlur={onBlur} 265 onKeyDown={onKeyDown}> 266 <Pressable 267 accessibilityRole="button" 268 onPointerEnter={onPointerMoveEmptySpace} 269 onPointerMove={onPointerMoveEmptySpace} 270 onPointerLeave={onPointerLeaveEmptySpace} 271 accessibilityHint={_( 272 !focused 273 ? msg`Unmute video` 274 : playing 275 ? msg`Pause video` 276 : msg`Play video`, 277 )} 278 style={[ 279 a.flex_1, 280 web({cursor: showCursor || !playing ? 'pointer' : 'none'}), 281 ]} 282 onPress={onPressEmptySpace} 283 /> 284 {!showControls && !focused && duration > 0 && ( 285 <TimeIndicator time={Math.floor(duration - currentTime)} /> 286 )} 287 <View 288 style={[ 289 a.flex_shrink_0, 290 a.w_full, 291 a.px_xs, 292 web({ 293 background: 294 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', 295 }), 296 {opacity: showControls ? 1 : 0}, 297 {transition: 'opacity 0.2s ease-in-out'}, 298 ]}> 299 <Scrubber 300 duration={duration} 301 currentTime={currentTime} 302 onSeek={onSeek} 303 onSeekStart={onSeekStart} 304 onSeekEnd={onSeekEnd} 305 seekLeft={seekLeft} 306 seekRight={seekRight} 307 togglePlayPause={togglePlayPause} 308 drawFocus={drawFocus} 309 /> 310 <View 311 style={[ 312 a.flex_1, 313 a.px_xs, 314 a.pt_sm, 315 a.pb_md, 316 a.gap_md, 317 a.flex_row, 318 a.align_center, 319 ]}> 320 <ControlButton 321 active={playing} 322 activeLabel={_(msg`Pause`)} 323 inactiveLabel={_(msg`Play`)} 324 activeIcon={PauseIcon} 325 inactiveIcon={PlayIcon} 326 onPress={onPressPlayPause} 327 /> 328 <View style={a.flex_1} /> 329 <Text style={{color: t.palette.white}}> 330 {formatTime(currentTime)} / {formatTime(duration)} 331 </Text> 332 {hasSubtitleTrack && ( 333 <ControlButton 334 active={subtitlesEnabled} 335 activeLabel={_(msg`Disable subtitles`)} 336 inactiveLabel={_(msg`Enable subtitles`)} 337 activeIcon={CCActiveIcon} 338 inactiveIcon={CCInactiveIcon} 339 onPress={onPressSubtitles} 340 /> 341 )} 342 <ControlButton 343 active={muted} 344 activeLabel={_(msg`Unmute`)} 345 inactiveLabel={_(msg`Mute`)} 346 activeIcon={MuteIcon} 347 inactiveIcon={UnmuteIcon} 348 onPress={onPressMute} 349 /> 350 {!isIPhoneWeb && ( 351 <ControlButton 352 active={isFullscreen} 353 activeLabel={_(msg`Exit fullscreen`)} 354 inactiveLabel={_(msg`Fullscreen`)} 355 activeIcon={ArrowsInIcon} 356 inactiveIcon={ArrowsOutIcon} 357 onPress={onPressFullscreen} 358 /> 359 )} 360 </View> 361 </View> 362 {(buffering || error) && ( 363 <View 364 pointerEvents="none" 365 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 366 {buffering && <Loader fill={t.palette.white} size="lg" />} 367 {error && ( 368 <Text style={{color: t.palette.white}}> 369 <Trans>An error occurred</Trans> 370 </Text> 371 )} 372 </View> 373 )} 374 </div> 375 ) 376} 377 378function ControlButton({ 379 active, 380 activeLabel, 381 inactiveLabel, 382 activeIcon: ActiveIcon, 383 inactiveIcon: InactiveIcon, 384 onPress, 385}: { 386 active: boolean 387 activeLabel: string 388 inactiveLabel: string 389 activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> 390 inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> 391 onPress: () => void 392}) { 393 const t = useTheme() 394 return ( 395 <Button 396 label={active ? activeLabel : inactiveLabel} 397 onPress={onPress} 398 variant="ghost" 399 shape="round" 400 size="medium" 401 style={a.p_2xs} 402 hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}> 403 {active ? ( 404 <ActiveIcon fill={t.palette.white} width={20} /> 405 ) : ( 406 <InactiveIcon fill={t.palette.white} width={20} /> 407 )} 408 </Button> 409 ) 410} 411 412function Scrubber({ 413 duration, 414 currentTime, 415 onSeek, 416 onSeekEnd, 417 onSeekStart, 418 seekLeft, 419 seekRight, 420 togglePlayPause, 421 drawFocus, 422}: { 423 duration: number 424 currentTime: number 425 onSeek: (time: number) => void 426 onSeekEnd: () => void 427 onSeekStart: () => void 428 seekLeft: () => void 429 seekRight: () => void 430 togglePlayPause: () => void 431 drawFocus: () => void 432}) { 433 const {_} = useLingui() 434 const t = useTheme() 435 const [scrubberActive, setScrubberActive] = useState(false) 436 const { 437 state: hovered, 438 onIn: onStartHover, 439 onOut: onEndHover, 440 } = useInteractionState() 441 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 442 const [seekPosition, setSeekPosition] = useState(0) 443 const isSeekingRef = useRef(false) 444 const barRef = useRef<HTMLDivElement>(null) 445 const circleRef = useRef<HTMLDivElement>(null) 446 447 const seek = useCallback( 448 (evt: React.PointerEvent<HTMLDivElement>) => { 449 if (!barRef.current) return 450 const {left, width} = barRef.current.getBoundingClientRect() 451 const x = evt.clientX 452 const percent = clamp((x - left) / width, 0, 1) * duration 453 onSeek(percent) 454 setSeekPosition(percent) 455 }, 456 [duration, onSeek], 457 ) 458 459 const onPointerDown = useCallback( 460 (evt: React.PointerEvent<HTMLDivElement>) => { 461 const target = evt.target 462 if (target instanceof Element) { 463 evt.preventDefault() 464 target.setPointerCapture(evt.pointerId) 465 isSeekingRef.current = true 466 seek(evt) 467 setScrubberActive(true) 468 onSeekStart() 469 } 470 }, 471 [seek, onSeekStart], 472 ) 473 474 const onPointerMove = useCallback( 475 (evt: React.PointerEvent<HTMLDivElement>) => { 476 if (isSeekingRef.current) { 477 evt.preventDefault() 478 seek(evt) 479 } 480 }, 481 [seek], 482 ) 483 484 const onPointerUp = useCallback( 485 (evt: React.PointerEvent<HTMLDivElement>) => { 486 const target = evt.target 487 if (isSeekingRef.current && target instanceof Element) { 488 evt.preventDefault() 489 target.releasePointerCapture(evt.pointerId) 490 isSeekingRef.current = false 491 onSeekEnd() 492 setScrubberActive(false) 493 } 494 }, 495 [onSeekEnd], 496 ) 497 498 useEffect(() => { 499 // HACK: there's divergent browser behaviour about what to do when 500 // a pointerUp event is fired outside the element that captured the 501 // pointer. Firefox clicks on the element the mouse is over, so we have 502 // to make everything unclickable while seeking -sfn 503 if (isFirefox && scrubberActive) { 504 document.body.classList.add('force-no-clicks') 505 506 return () => { 507 document.body.classList.remove('force-no-clicks') 508 } 509 } 510 }, [scrubberActive, onSeekEnd]) 511 512 useEffect(() => { 513 if (!circleRef.current) return 514 if (focused) { 515 const abortController = new AbortController() 516 const {signal} = abortController 517 circleRef.current.addEventListener( 518 'keydown', 519 evt => { 520 // space: play/pause 521 // arrow left: seek backward 522 // arrow right: seek forward 523 524 if (evt.key === ' ') { 525 evt.preventDefault() 526 drawFocus() 527 togglePlayPause() 528 } else if (evt.key === 'ArrowLeft') { 529 evt.preventDefault() 530 drawFocus() 531 seekLeft() 532 } else if (evt.key === 'ArrowRight') { 533 evt.preventDefault() 534 drawFocus() 535 seekRight() 536 } 537 }, 538 {signal}, 539 ) 540 541 return () => abortController.abort() 542 } 543 }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) 544 545 const progress = scrubberActive ? seekPosition : currentTime 546 const progressPercent = (progress / duration) * 100 547 548 return ( 549 <View 550 testID="scrubber" 551 style={[{height: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]} 552 onPointerEnter={onStartHover} 553 onPointerLeave={onEndHover}> 554 <div 555 ref={barRef} 556 style={{ 557 flex: 1, 558 display: 'flex', 559 alignItems: 'center', 560 position: 'relative', 561 cursor: scrubberActive ? 'grabbing' : 'grab', 562 }} 563 onPointerDown={onPointerDown} 564 onPointerMove={onPointerMove} 565 onPointerUp={onPointerUp} 566 onPointerCancel={onPointerUp}> 567 <View 568 style={[ 569 a.w_full, 570 a.rounded_full, 571 a.overflow_hidden, 572 {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, 573 {height: hovered || scrubberActive ? 6 : 3}, 574 ]}> 575 {duration > 0 && ( 576 <View 577 style={[ 578 a.h_full, 579 {backgroundColor: t.palette.white}, 580 {width: `${progressPercent}%`}, 581 ]} 582 /> 583 )} 584 </View> 585 <div 586 ref={circleRef} 587 aria-label={_(msg`Seek slider`)} 588 role="slider" 589 aria-valuemax={duration} 590 aria-valuemin={0} 591 aria-valuenow={currentTime} 592 aria-valuetext={_( 593 msg`${formatTime(currentTime)} of ${formatTime(duration)}`, 594 )} 595 tabIndex={0} 596 onFocus={onFocus} 597 onBlur={onBlur} 598 style={{ 599 position: 'absolute', 600 height: 16, 601 width: 16, 602 left: `calc(${progressPercent}% - 8px)`, 603 borderRadius: 8, 604 pointerEvents: 'none', 605 }}> 606 <View 607 style={[ 608 a.w_full, 609 a.h_full, 610 a.rounded_full, 611 {backgroundColor: t.palette.white}, 612 { 613 transform: [ 614 { 615 scale: 616 hovered || scrubberActive || focused 617 ? scrubberActive 618 ? 1 619 : 0.6 620 : 0, 621 }, 622 ], 623 }, 624 ]} 625 /> 626 </div> 627 </div> 628 </View> 629 ) 630} 631 632function formatTime(time: number) { 633 if (isNaN(time)) { 634 return '--' 635 } 636 637 time = Math.round(time) 638 639 const minutes = Math.floor(time / 60) 640 const seconds = String(time % 60).padStart(2, '0') 641 642 return `${minutes}:${seconds}` 643} 644 645function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) { 646 const [playing, setPlaying] = useState(false) 647 const [muted, setMuted] = useState(true) 648 const [currentTime, setCurrentTime] = useState(0) 649 const [duration, setDuration] = useState(0) 650 const [buffering, setBuffering] = useState(false) 651 const [error, setError] = useState(false) 652 const [canPlay, setCanPlay] = useState(false) 653 const playWhenReadyRef = useRef(false) 654 655 useEffect(() => { 656 if (!ref.current) return 657 658 let bufferingTimeout: ReturnType<typeof setTimeout> | undefined 659 660 function round(num: number) { 661 return Math.round(num * 100) / 100 662 } 663 664 // Initial values 665 setCurrentTime(round(ref.current.currentTime) || 0) 666 setDuration(round(ref.current.duration) || 0) 667 setMuted(ref.current.muted) 668 setPlaying(!ref.current.paused) 669 670 const handleTimeUpdate = () => { 671 if (!ref.current) return 672 setCurrentTime(round(ref.current.currentTime) || 0) 673 } 674 675 const handleDurationChange = () => { 676 if (!ref.current) return 677 setDuration(round(ref.current.duration) || 0) 678 } 679 680 const handlePlay = () => { 681 setPlaying(true) 682 } 683 684 const handlePause = () => { 685 setPlaying(false) 686 } 687 688 const handleVolumeChange = () => { 689 if (!ref.current) return 690 setMuted(ref.current.muted) 691 } 692 693 const handleError = () => { 694 setError(true) 695 } 696 697 const handleCanPlay = () => { 698 setBuffering(false) 699 setCanPlay(true) 700 701 if (!ref.current) return 702 if (playWhenReadyRef.current) { 703 ref.current.play() 704 playWhenReadyRef.current = false 705 } 706 } 707 708 const handleCanPlayThrough = () => { 709 setBuffering(false) 710 } 711 712 const handleWaiting = () => { 713 if (bufferingTimeout) clearTimeout(bufferingTimeout) 714 bufferingTimeout = setTimeout(() => { 715 setBuffering(true) 716 }, 200) // Delay to avoid frequent buffering state changes 717 } 718 719 const handlePlaying = () => { 720 if (bufferingTimeout) clearTimeout(bufferingTimeout) 721 setBuffering(false) 722 setError(false) 723 } 724 725 const handleStalled = () => { 726 if (bufferingTimeout) clearTimeout(bufferingTimeout) 727 bufferingTimeout = setTimeout(() => { 728 setBuffering(true) 729 }, 200) // Delay to avoid frequent buffering state changes 730 } 731 732 const handleEnded = () => { 733 setPlaying(false) 734 setBuffering(false) 735 setError(false) 736 } 737 738 const abortController = new AbortController() 739 740 ref.current.addEventListener('timeupdate', handleTimeUpdate, { 741 signal: abortController.signal, 742 }) 743 ref.current.addEventListener('durationchange', handleDurationChange, { 744 signal: abortController.signal, 745 }) 746 ref.current.addEventListener('play', handlePlay, { 747 signal: abortController.signal, 748 }) 749 ref.current.addEventListener('pause', handlePause, { 750 signal: abortController.signal, 751 }) 752 ref.current.addEventListener('volumechange', handleVolumeChange, { 753 signal: abortController.signal, 754 }) 755 ref.current.addEventListener('error', handleError, { 756 signal: abortController.signal, 757 }) 758 ref.current.addEventListener('canplay', handleCanPlay, { 759 signal: abortController.signal, 760 }) 761 ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { 762 signal: abortController.signal, 763 }) 764 ref.current.addEventListener('waiting', handleWaiting, { 765 signal: abortController.signal, 766 }) 767 ref.current.addEventListener('playing', handlePlaying, { 768 signal: abortController.signal, 769 }) 770 ref.current.addEventListener('stalled', handleStalled, { 771 signal: abortController.signal, 772 }) 773 ref.current.addEventListener('ended', handleEnded, { 774 signal: abortController.signal, 775 }) 776 777 return () => { 778 abortController.abort() 779 clearTimeout(bufferingTimeout) 780 } 781 }, [ref]) 782 783 const play = useCallback(() => { 784 if (!ref.current) return 785 786 if (ref.current.ended) { 787 ref.current.currentTime = 0 788 } 789 790 if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { 791 playWhenReadyRef.current = true 792 } else { 793 const promise = ref.current.play() 794 if (promise !== undefined) { 795 promise.catch(err => { 796 console.error('Error playing video:', err) 797 }) 798 } 799 } 800 }, [ref]) 801 802 const pause = useCallback(() => { 803 if (!ref.current) return 804 805 ref.current.pause() 806 playWhenReadyRef.current = false 807 }, [ref]) 808 809 const togglePlayPause = useCallback(() => { 810 if (!ref.current) return 811 812 if (ref.current.paused) { 813 play() 814 } else { 815 pause() 816 } 817 }, [ref, play, pause]) 818 819 const mute = useCallback(() => { 820 if (!ref.current) return 821 822 ref.current.muted = true 823 }, [ref]) 824 825 const unmute = useCallback(() => { 826 if (!ref.current) return 827 828 ref.current.muted = false 829 }, [ref]) 830 831 const toggleMute = useCallback(() => { 832 if (!ref.current) return 833 834 ref.current.muted = !ref.current.muted 835 }, [ref]) 836 837 return { 838 play, 839 pause, 840 togglePlayPause, 841 duration, 842 currentTime, 843 playing, 844 muted, 845 mute, 846 unmute, 847 toggleMute, 848 buffering, 849 error, 850 canPlay, 851 } 852} 853 854function fullscreenSubscribe(onChange: () => void) { 855 document.addEventListener('fullscreenchange', onChange) 856 return () => document.removeEventListener('fullscreenchange', onChange) 857} 858 859function useFullscreen(ref: React.RefObject<HTMLElement>) { 860 const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () => 861 Boolean(document.fullscreenElement), 862 ) 863 864 const toggleFullscreen = useCallback(() => { 865 if (isFullscreen) { 866 document.exitFullscreen() 867 } else { 868 if (!ref.current) return 869 ref.current.requestFullscreen() 870 } 871 }, [isFullscreen, ref]) 872 873 return [isFullscreen, toggleFullscreen] as const 874}