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;
+ }