mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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}