about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-08-26 22:28:45 +0100
committerGitHub <noreply@github.com>2024-08-26 22:28:45 +0100
commit9b534b968da2a87e2cfc0c8e62cda127f98edae1 (patch)
tree09e0faf84cd700088f80707298c58cf327f89a4c /src
parentdef9dda29c7fb08edc4cbf5d659221b976413a05 (diff)
downloadvoidsky-9b534b968da2a87e2cfc0c8e62cda127f98edae1.tar.zst
[Video] add scrubber to the web player (#4943)
Diffstat (limited to 'src')
-rw-r--r--src/components/hooks/useInteractionState.ts4
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx492
2 files changed, 382 insertions, 114 deletions
diff --git a/src/components/hooks/useInteractionState.ts b/src/components/hooks/useInteractionState.ts
index 653b1c10e..67042d4a8 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 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,
     })