From 9b534b968da2a87e2cfc0c8e62cda127f98edae1 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 26 Aug 2024 22:28:45 +0100 Subject: [PATCH] [Video] add scrubber to the web player (#4943) --- bskyweb/templates/base.html | 5 + src/components/hooks/useInteractionState.ts | 4 +- .../VideoEmbedInner/VideoWebControls.tsx | 492 ++++++++++++++---- web/index.html | 5 + 4 files changed, 392 insertions(+), 114 deletions(-) diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index cb2caed4..c2480279 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -253,6 +253,11 @@ from { opacity: 1; } to { opacity: 0; } } + + .force-no-clicks > *, + .force-no-clicks * { + pointer-events: none !important; + } {% include "scripts.html" %} diff --git a/src/components/hooks/useInteractionState.ts b/src/components/hooks/useInteractionState.ts index 653b1c10..67042d4a 100644 --- a/src/components/hooks/useInteractionState.ts +++ b/src/components/hooks/useInteractionState.ts @@ -5,10 +5,10 @@ export function useInteractionState() { const onIn = React.useCallback(() => { setState(true) - }, [setState]) + }, []) const onOut = React.useCallback(() => { setState(false) - }, [setState]) + }, []) return React.useMemo( () => ({ diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx index 7caaf3ab..09524b91 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({ - - - - {formatTime(currentTime)} / {formatTime(duration)} - - {hasSubtitleTrack && ( - - )} - - {!isIPhoneWeb && ( - - )} - - {(showControls || !focused) && ( - + - {duration > 0 && ( - + + + {formatTime(currentTime)} / {formatTime(duration)} + + {hasSubtitleTrack && ( + )} - - )} + + {!isIPhoneWeb && ( + + )} + + {(buffering || error) && ( - {buffering && } {error && ( @@ -314,19 +337,278 @@ export function Controls({ An error occurred )} - + )} ) } -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> + inactiveIcon: React.ComponentType> + onPress: () => void +}) { + const t = useTheme() + return ( + + ) +} + +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(null) + const circleRef = useRef(null) + + const seek = useCallback( + (evt: React.PointerEvent) => { + 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) => { + 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) => { + if (isSeekingRef.current) { + evt.preventDefault() + seek(evt) + } + }, + [seek], + ) + + const onPointerUp = useCallback( + (evt: React.PointerEvent) => { + 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 ( + +
+ + {currentTime && duration && ( + + )} + +
+ +
+
+
+ ) +} function formatTime(time: number) { if (isNaN(time)) { @@ -421,14 +703,6 @@ function useVideoUtils(ref: React.RefObject) { 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) { 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, }) diff --git a/web/index.html b/web/index.html index 81cbc233..825d1596 100644 --- a/web/index.html +++ b/web/index.html @@ -257,6 +257,11 @@ from { opacity: 1; } to { opacity: 0; } } + + .force-no-clicks > *, + .force-no-clicks * { + pointer-events: none !important; + }