diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-08-26 22:28:45 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-08-26 22:28:45 +0100 |
commit | 9b534b968da2a87e2cfc0c8e62cda127f98edae1 (patch) | |
tree | 09e0faf84cd700088f80707298c58cf327f89a4c /src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx | |
parent | def9dda29c7fb08edc4cbf5d659221b976413a05 (diff) | |
download | voidsky-9b534b968da2a87e2cfc0c8e62cda127f98edae1.tar.zst |
[Video] add scrubber to the web player (#4943)
Diffstat (limited to 'src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx')
-rw-r--r-- | src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx | 492 |
1 files changed, 380 insertions, 112 deletions
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx index 7caaf3abf..09524b91c 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx @@ -6,17 +6,19 @@ import React, { useSyncExternalStore, } from 'react' import {Pressable, View} from 'react-native' -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {SvgProps} from 'react-native-svg' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import type Hls from 'hls.js' -import {isIPhoneWeb} from 'platform/detection' +import {isFirefox} from '#/lib/browser' +import {clamp} from '#/lib/numbers' +import {isIPhoneWeb} from '#/platform/detection' import { useAutoplayDisabled, useSetSubtitlesEnabled, useSubtitlesEnabled, -} from 'state/preferences' +} from '#/state/preferences' import {atoms as a, useTheme, web} from '#/alf' import {Button} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -173,6 +175,50 @@ export function Controls({ toggleFullscreen() }, [drawFocus, toggleFullscreen]) + const onSeek = useCallback( + (time: number) => { + if (!videoRef.current) return + if (videoRef.current.fastSeek) { + videoRef.current.fastSeek(time) + } else { + videoRef.current.currentTime = time + } + }, + [videoRef], + ) + + const playStateBeforeSeekRef = useRef(false) + + const onSeekStart = useCallback(() => { + drawFocus() + playStateBeforeSeekRef.current = playing + pause() + }, [playing, pause, drawFocus]) + + const onSeekEnd = useCallback(() => { + if (playStateBeforeSeekRef.current) { + play() + } + }, [play]) + + const seekLeft = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime - 5, 0, duration)) + }, [onSeek, videoRef]) + + const seekRight = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime + 5, 0, duration)) + }, [onSeek, videoRef]) + const showControls = (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) @@ -197,7 +243,7 @@ export function Controls({ <Pressable accessibilityRole="button" accessibilityHint={_( - focused + !focused ? msg`Unmute video` : playing ? msg`Pause video` @@ -210,103 +256,80 @@ export function Controls({ style={[ a.flex_shrink_0, a.w_full, - a.px_sm, - a.pt_sm, - a.pb_md, - a.gap_md, - a.flex_row, - a.align_center, + a.px_xs, web({ background: 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', }), - showControls ? {opacity: 1} : {opacity: 0}, + {opacity: showControls ? 1 : 0}, + {transition: 'opacity 0.2s ease-in-out'}, ]}> - <Button - label={_(playing ? msg`Pause` : msg`Play`)} - onPress={onPressPlayPause} - {...btnProps}> - {playing ? ( - <PauseIcon fill={t.palette.white} width={20} /> - ) : ( - <PlayIcon fill={t.palette.white} width={20} /> - )} - </Button> - <View style={a.flex_1} /> - <Text style={{color: t.palette.white}}> - {formatTime(currentTime)} / {formatTime(duration)} - </Text> - {hasSubtitleTrack && ( - <Button - label={_( - subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`, - )} - onPress={onPressSubtitles} - {...btnProps}> - {subtitlesEnabled ? ( - <CCActiveIcon fill={t.palette.white} width={20} /> - ) : ( - <CCInactiveIcon fill={t.palette.white} width={20} /> - )} - </Button> - )} - <Button - label={_(muted ? msg`Unmute` : msg`Mute`)} - onPress={onPressMute} - {...btnProps}> - {muted ? ( - <MuteIcon fill={t.palette.white} width={20} /> - ) : ( - <UnmuteIcon fill={t.palette.white} width={20} /> - )} - </Button> - {!isIPhoneWeb && ( - <Button - label={_(muted ? msg`Unmute` : msg`Mute`)} - onPress={onPressFullscreen} - {...btnProps}> - {isFullscreen ? ( - <ArrowsInIcon fill={t.palette.white} width={20} /> - ) : ( - <ArrowsOutIcon fill={t.palette.white} width={20} /> - )} - </Button> - )} - </View> - {(showControls || !focused) && ( - <Animated.View - entering={FadeIn.duration(200)} - exiting={FadeOut.duration(200)} + <Scrubber + duration={duration} + currentTime={currentTime} + onSeek={onSeek} + onSeekStart={onSeekStart} + onSeekEnd={onSeekEnd} + seekLeft={seekLeft} + seekRight={seekRight} + togglePlayPause={togglePlayPause} + drawFocus={drawFocus} + /> + <View style={[ - a.absolute, - { - height: 5, - bottom: 0, - left: 0, - right: 0, - backgroundColor: 'rgba(255,255,255,0.4)', - }, + a.flex_1, + a.px_xs, + a.pt_sm, + a.pb_md, + a.gap_md, + a.flex_row, + a.align_center, ]}> - {duration > 0 && ( - <View - style={[ - a.h_full, - a.mr_auto, - { - backgroundColor: t.palette.white, - width: `${(currentTime / duration) * 100}%`, - opacity: 0.8, - }, - ]} + <ControlButton + active={playing} + activeLabel={_(msg`Pause`)} + inactiveLabel={_(msg`Play`)} + activeIcon={PauseIcon} + inactiveIcon={PlayIcon} + onPress={onPressPlayPause} + /> + <View style={a.flex_1} /> + <Text style={{color: t.palette.white}}> + {formatTime(currentTime)} / {formatTime(duration)} + </Text> + {hasSubtitleTrack && ( + <ControlButton + active={subtitlesEnabled} + activeLabel={_(msg`Disable subtitles`)} + inactiveLabel={_(msg`Enable subtitles`)} + activeIcon={CCActiveIcon} + inactiveIcon={CCInactiveIcon} + onPress={onPressSubtitles} /> )} - </Animated.View> - )} + <ControlButton + active={muted} + activeLabel={_(msg`Unmute`)} + inactiveLabel={_(msg`Mute`)} + activeIcon={MuteIcon} + inactiveIcon={UnmuteIcon} + onPress={onPressMute} + /> + {!isIPhoneWeb && ( + <ControlButton + active={isFullscreen} + activeLabel={_(msg`Exit fullscreen`)} + inactiveLabel={_(msg`Fullscreen`)} + activeIcon={ArrowsInIcon} + inactiveIcon={ArrowsOutIcon} + onPress={onPressFullscreen} + /> + )} + </View> + </View> {(buffering || error) && ( - <Animated.View + <View pointerEvents="none" - entering={FadeIn.delay(1000).duration(200)} - exiting={FadeOut.duration(200)} style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> {buffering && <Loader fill={t.palette.white} size="lg" />} {error && ( @@ -314,19 +337,278 @@ export function Controls({ <Trans>An error occurred</Trans> </Text> )} - </Animated.View> + </View> )} </div> ) } -const btnProps = { - variant: 'ghost', - shape: 'round', - size: 'medium', - style: a.p_2xs, - hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'}, -} as const +function ControlButton({ + active, + activeLabel, + inactiveLabel, + activeIcon: ActiveIcon, + inactiveIcon: InactiveIcon, + onPress, +}: { + active: boolean + activeLabel: string + inactiveLabel: string + activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> + inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> + onPress: () => void +}) { + const t = useTheme() + return ( + <Button + label={active ? activeLabel : inactiveLabel} + onPress={onPress} + variant="ghost" + shape="round" + size="medium" + style={a.p_2xs} + hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}> + {active ? ( + <ActiveIcon fill={t.palette.white} width={20} /> + ) : ( + <InactiveIcon fill={t.palette.white} width={20} /> + )} + </Button> + ) +} + +function Scrubber({ + duration, + currentTime, + onSeek, + onSeekEnd, + onSeekStart, + seekLeft, + seekRight, + togglePlayPause, + drawFocus, +}: { + duration: number + currentTime: number + onSeek: (time: number) => void + onSeekEnd: () => void + onSeekStart: () => void + seekLeft: () => void + seekRight: () => void + togglePlayPause: () => void + drawFocus: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const [scrubberActive, setScrubberActive] = useState(false) + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [seekPosition, setSeekPosition] = useState(0) + const isSeekingRef = useRef(false) + const barRef = useRef<HTMLDivElement>(null) + const circleRef = useRef<HTMLDivElement>(null) + + const seek = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + if (!barRef.current) return + const {left, width} = barRef.current.getBoundingClientRect() + const x = evt.clientX + const percent = clamp((x - left) / width, 0, 1) * duration + onSeek(percent) + setSeekPosition(percent) + }, + [duration, onSeek], + ) + + const onPointerDown = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + const target = evt.target + if (target instanceof Element) { + evt.preventDefault() + target.setPointerCapture(evt.pointerId) + isSeekingRef.current = true + seek(evt) + setScrubberActive(true) + onSeekStart() + } + }, + [seek, onSeekStart], + ) + + const onPointerMove = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + if (isSeekingRef.current) { + evt.preventDefault() + seek(evt) + } + }, + [seek], + ) + + const onPointerUp = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + const target = evt.target + if (isSeekingRef.current && target instanceof Element) { + evt.preventDefault() + target.releasePointerCapture(evt.pointerId) + isSeekingRef.current = false + onSeekEnd() + setScrubberActive(false) + } + }, + [onSeekEnd], + ) + + useEffect(() => { + // HACK: there's divergent browser behaviour about what to do when + // a pointerUp event is fired outside the element that captured the + // pointer. Firefox clicks on the element the mouse is over, so we have + // to make everything unclickable while seeking -sfn + if (isFirefox && scrubberActive) { + document.body.classList.add('force-no-clicks') + + const abortController = new AbortController() + const {signal} = abortController + document.documentElement.addEventListener( + 'mouseleave', + () => { + isSeekingRef.current = false + onSeekEnd() + setScrubberActive(false) + }, + {signal}, + ) + + return () => { + document.body.classList.remove('force-no-clicks') + abortController.abort() + } + } + }, [scrubberActive, onSeekEnd]) + + useEffect(() => { + if (!circleRef.current) return + if (focused) { + const abortController = new AbortController() + const {signal} = abortController + circleRef.current.addEventListener( + 'keydown', + evt => { + // space: play/pause + // arrow left: seek backward + // arrow right: seek forward + + if (evt.key === ' ') { + evt.preventDefault() + drawFocus() + togglePlayPause() + } else if (evt.key === 'ArrowLeft') { + evt.preventDefault() + drawFocus() + seekLeft() + } else if (evt.key === 'ArrowRight') { + evt.preventDefault() + drawFocus() + seekRight() + } + }, + {signal}, + ) + + return () => abortController.abort() + } + }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) + + const progress = scrubberActive ? seekPosition : currentTime + const progressPercent = (progress / duration) * 100 + + return ( + <View + testID="scrubber" + style={[{height: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]} + // @ts-expect-error web only -sfn + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave}> + <div + ref={barRef} + style={{ + flex: 1, + display: 'flex', + alignItems: 'center', + position: 'relative', + cursor: scrubberActive ? 'grabbing' : 'grab', + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp}> + <View + style={[ + a.w_full, + a.rounded_full, + a.overflow_hidden, + {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, + {height: hovered || scrubberActive ? 6 : 3}, + ]}> + {currentTime && duration && ( + <View + style={[ + a.h_full, + {backgroundColor: t.palette.white}, + {width: `${progressPercent}%`}, + ]} + /> + )} + </View> + <div + ref={circleRef} + aria-label={_(msg`Seek slider`)} + role="slider" + aria-valuemax={duration} + aria-valuemin={0} + aria-valuenow={currentTime} + aria-valuetext={_( + msg`${formatTime(currentTime)} of ${formatTime(duration)}`, + )} + tabIndex={0} + onFocus={onFocus} + onBlur={onBlur} + style={{ + position: 'absolute', + height: 16, + width: 16, + left: `calc(${progressPercent}% - 8px)`, + borderRadius: 8, + pointerEvents: 'none', + }}> + <View + style={[ + a.w_full, + a.h_full, + a.rounded_full, + {backgroundColor: t.palette.white}, + { + transform: [ + { + scale: + hovered || scrubberActive || focused + ? scrubberActive + ? 1 + : 0.6 + : 0, + }, + ], + }, + ]} + /> + </div> + </div> + </View> + ) +} function formatTime(time: number) { if (isNaN(time)) { @@ -421,14 +703,6 @@ function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) { setError(false) } - const handleSeeking = () => { - setBuffering(true) - } - - const handleSeeked = () => { - setBuffering(false) - } - const handleStalled = () => { if (bufferingTimeout) clearTimeout(bufferingTimeout) bufferingTimeout = setTimeout(() => { @@ -474,12 +748,6 @@ function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) { ref.current.addEventListener('playing', handlePlaying, { signal: abortController.signal, }) - ref.current.addEventListener('seeking', handleSeeking, { - signal: abortController.signal, - }) - ref.current.addEventListener('seeked', handleSeeked, { - signal: abortController.signal, - }) ref.current.addEventListener('stalled', handleStalled, { signal: abortController.signal, }) |