about summary refs log tree commit diff
path: root/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-06-13 12:05:41 -0500
committerGitHub <noreply@github.com>2025-06-13 12:05:41 -0500
commit45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 (patch)
treea2fd6917867f18fe334b54dd3289775c2930bc85 /src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
parentba0f5a9bdef5bd0447ded23cab1af222b65511cc (diff)
downloadvoidsky-45f0f7eefecae1922c2f30d4e7760d2b93b1ae56.tar.zst
Port post embeds to new arch (#7408)
* Direct port of embeds to new arch

(cherry picked from commit cc3fa1f6cea396dd9222486c633a508bfee1ecd6)

* Re-org

* Split out ListEmbed and FeedEmbed

* Split out ImageEmbed

* DRY up a bit

* Port over ExternalLinkEmbed

* Port over Player and Gif embeds

* Migrate ComposerReplyTo

* Replace other usages of old post-embeds

* Migrate view contexts

* Copy pasta VideoEmbed

* Copy pasta GifEmbed

* Swap in new file location

* Clean up

* Fix up native

* Add back in correct moderation on List and Feed embeds

* Format

* Prettier

* delete old video utils

* move bandwidth-estimate.ts

* Remove log

* Add LazyQuoteEmbed for composer use

* Clean up unused things

* Remove remaining items

* Prettier

* Fix imports

* Handle nested quotes same as prod

* Add back silenced error handling

* Fix lint

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx')
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx238
1 files changed, 238 insertions, 0 deletions
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
new file mode 100644
index 000000000..96960bad4
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
@@ -0,0 +1,238 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isFirefox, isTouchDevice} from '#/lib/browser'
+import {clamp} from '#/lib/numbers'
+import {atoms as a, useTheme, web} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {formatTime} from './utils'
+
+export 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: onStartHover,
+    onOut: onEndHover,
+  } = 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')
+
+      return () => {
+        document.body.classList.remove('force-no-clicks')
+      }
+    }
+  }, [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: isTouchDevice ? 32 : 18, width: '100%'},
+        a.flex_shrink_0,
+        a.px_xs,
+      ]}
+      onPointerEnter={onStartHover}
+      onPointerLeave={onEndHover}>
+      <div
+        ref={barRef}
+        style={{
+          flex: 1,
+          display: 'flex',
+          alignItems: 'center',
+          position: 'relative',
+          cursor: scrubberActive ? 'grabbing' : 'grab',
+          padding: '4px 0',
+        }}
+        onPointerDown={onPointerDown}
+        onPointerMove={onPointerMove}
+        onPointerUp={onPointerUp}
+        onPointerCancel={onPointerUp}>
+        <View
+          style={[
+            a.w_full,
+            a.rounded_full,
+            a.overflow_hidden,
+            {backgroundColor: 'rgba(255, 255, 255, 0.4)'},
+            {height: hovered || scrubberActive ? 6 : 3},
+            web({transition: 'height 0.1s ease'}),
+          ]}>
+          {duration > 0 && (
+            <View
+              style={[
+                a.h_full,
+                {backgroundColor: t.palette.white},
+                {width: `${progressPercent}%`},
+              ]}
+            />
+          )}
+        </View>
+        <div
+          ref={circleRef}
+          aria-label={_(
+            msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`,
+          )}
+          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>
+  )
+}