about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-09-16 21:37:33 +0100
committerGitHub <noreply@github.com>2024-09-16 21:37:33 +0100
commit8241747fc22bb4363ff6cf48d54013cc72db7624 (patch)
treee6cd31d82100fb9c99f3443d7b2753672b55373c /src
parent38c8f01594ff515fbe49d00a777d70449e804fd4 (diff)
downloadvoidsky-8241747fc22bb4363ff6cf48d54013cc72db7624.tar.zst
[Video] Volume controls on web (#5363)
* split up VideoWebControls

* add basic slider

* logarithmic volume

* integrate mute state

* fix typo

* shared video volume

* rm log

* animate in/out

* disable for touch devices

* remove flicker on touch devices

* more detailed comment

* move into correct context provider

* add minHeight

* hack

* bettern umber

---------

Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'src')
-rw-r--r--src/view/com/util/post-embeds/ActiveVideoWebContext.tsx2
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx6
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx2
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx898
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx39
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx231
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx)0
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx423
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx109
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx228
-rw-r--r--src/view/com/util/post-embeds/VideoVolumeContext.tsx31
11 files changed, 1058 insertions, 911 deletions
diff --git a/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx b/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx
index bc43e997c..a038403b2 100644
--- a/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx
+++ b/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx
@@ -18,7 +18,7 @@ const Context = React.createContext<{
 
 export function Provider({children}: {children: React.ReactNode}) {
   if (!isWeb) {
-    throw new Error('ActiveVideoWebContext may onl be used on web.')
+    throw new Error('ActiveVideoWebContext may only be used on web.')
   }
 
   const [activeViewId, setActiveViewId] = useState<string | null>(null)
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
index afa9aa305..d21ce61e5 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -9,7 +9,7 @@ import {useLingui} from '@lingui/react'
 import {HITSLOP_30} from '#/lib/constants'
 import {clamp} from '#/lib/numbers'
 import {useAutoplayDisabled} from '#/state/preferences'
-import {useVideoVolumeState} from 'view/com/util/post-embeds/VideoVolumeContext'
+import {useVideoMuteState} from 'view/com/util/post-embeds/VideoVolumeContext'
 import {atoms as a, useTheme} from '#/alf'
 import {useIsWithinMessage} from '#/components/dms/MessageContext'
 import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
@@ -38,7 +38,7 @@ export const VideoEmbedInnerNative = React.forwardRef(
     const videoRef = useRef<BlueskyVideoView>(null)
     const autoplayDisabled = useAutoplayDisabled()
     const isWithinMessage = useIsWithinMessage()
-    const {muted, setMuted} = useVideoVolumeState()
+    const [muted, setMuted] = useVideoMuteState()
 
     const [isPlaying, setIsPlaying] = React.useState(false)
     const [timeRemaining, setTimeRemaining] = React.useState(0)
@@ -128,7 +128,7 @@ function VideoControls({
 }) {
   const {_} = useLingui()
   const t = useTheme()
-  const {muted} = useVideoVolumeState()
+  const [muted] = useVideoMuteState()
 
   // show countdown when:
   // 1. timeRemaining is a number - was seeing NaNs
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index 5f569a818..90a21254f 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -5,7 +5,7 @@ import Hls from 'hls.js'
 
 import {atoms as a} from '#/alf'
 import {MediaInsetBorder} from '#/components/MediaInsetBorder'
-import {Controls} from './VideoWebControls'
+import {Controls} from './web-controls/VideoControls'
 
 export function VideoEmbedInnerWeb({
   embed,
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
deleted file mode 100644
index 97c52a0db..000000000
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
+++ /dev/null
@@ -1,898 +0,0 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react'
-import {Pressable, View} from 'react-native'
-import {SvgProps} from 'react-native-svg'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import type Hls from 'hls.js'
-
-import {isFirefox} from '#/lib/browser'
-import {clamp} from '#/lib/numbers'
-import {isIPhoneWeb} from '#/platform/detection'
-import {
-  useAutoplayDisabled,
-  useSetSubtitlesEnabled,
-  useSubtitlesEnabled,
-} from '#/state/preferences'
-import {atoms as a, useTheme, web} from '#/alf'
-import {Button} from '#/components/Button'
-import {useIsWithinMessage} from '#/components/dms/MessageContext'
-import {useFullscreen} from '#/components/hooks/useFullscreen'
-import {useInteractionState} from '#/components/hooks/useInteractionState'
-import {
-  ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
-  ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
-} from '#/components/icons/ArrowsDiagonal'
-import {
-  CC_Filled_Corner0_Rounded as CCActiveIcon,
-  CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
-} from '#/components/icons/CC'
-import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
-import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
-import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
-import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
-import {Loader} from '#/components/Loader'
-import {Text} from '#/components/Typography'
-import {TimeIndicator} from './TimeIndicator'
-
-export function Controls({
-  videoRef,
-  hlsRef,
-  active,
-  setActive,
-  focused,
-  setFocused,
-  onScreen,
-  fullscreenRef,
-  hasSubtitleTrack,
-}: {
-  videoRef: React.RefObject<HTMLVideoElement>
-  hlsRef: React.RefObject<Hls | undefined>
-  active: boolean
-  setActive: () => void
-  focused: boolean
-  setFocused: (focused: boolean) => void
-  onScreen: boolean
-  fullscreenRef: React.RefObject<HTMLDivElement>
-  hasSubtitleTrack: boolean
-}) {
-  const {
-    play,
-    pause,
-    playing,
-    muted,
-    toggleMute,
-    togglePlayPause,
-    currentTime,
-    duration,
-    buffering,
-    error,
-    canPlay,
-  } = useVideoUtils(videoRef)
-  const t = useTheme()
-  const {_} = useLingui()
-  const subtitlesEnabled = useSubtitlesEnabled()
-  const setSubtitlesEnabled = useSetSubtitlesEnabled()
-  const {
-    state: hovered,
-    onIn: onHover,
-    onOut: onEndHover,
-  } = useInteractionState()
-  const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
-  const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
-  const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
-
-  const onKeyDown = useCallback(() => {
-    setInteractingViaKeypress(true)
-  }, [])
-
-  useEffect(() => {
-    if (interactingViaKeypress) {
-      document.addEventListener('click', () => setInteractingViaKeypress(false))
-      return () => {
-        document.removeEventListener('click', () =>
-          setInteractingViaKeypress(false),
-        )
-      }
-    }
-  }, [interactingViaKeypress])
-
-  useEffect(() => {
-    if (isFullscreen) {
-      document.documentElement.style.scrollbarGutter = 'unset'
-      return () => {
-        document.documentElement.style.removeProperty('scrollbar-gutter')
-      }
-    }
-  }, [isFullscreen])
-
-  // pause + unfocus when another video is active
-  useEffect(() => {
-    if (!active) {
-      pause()
-      setFocused(false)
-    }
-  }, [active, pause, setFocused])
-
-  // autoplay/pause based on visibility
-  const isWithinMessage = useIsWithinMessage()
-  const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
-  useEffect(() => {
-    if (active) {
-      if (onScreen) {
-        if (!autoplayDisabled) play()
-      } else {
-        pause()
-      }
-    }
-  }, [onScreen, pause, active, play, autoplayDisabled])
-
-  // use minimal quality when not focused
-  useEffect(() => {
-    if (!hlsRef.current) return
-    if (focused) {
-      // auto decide quality based on network conditions
-      hlsRef.current.autoLevelCapping = -1
-      // allow 30s of buffering
-      hlsRef.current.config.maxMaxBufferLength = 30
-    } else {
-      // back to what we initially set
-      hlsRef.current.autoLevelCapping = 0
-      hlsRef.current.config.maxMaxBufferLength = 10
-    }
-  }, [hlsRef, focused])
-
-  useEffect(() => {
-    if (!hlsRef.current) return
-    if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
-      hlsRef.current.subtitleTrack = 0
-    } else {
-      hlsRef.current.subtitleTrack = -1
-    }
-  }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
-
-  // clicking on any button should focus the player, if it's not already focused
-  const drawFocus = useCallback(() => {
-    if (!active) {
-      setActive()
-    }
-    setFocused(true)
-  }, [active, setActive, setFocused])
-
-  const onPressEmptySpace = useCallback(() => {
-    if (!focused) {
-      drawFocus()
-      if (autoplayDisabled) play()
-    } else {
-      togglePlayPause()
-    }
-  }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
-
-  const onPressPlayPause = useCallback(() => {
-    drawFocus()
-    togglePlayPause()
-  }, [drawFocus, togglePlayPause])
-
-  const onPressSubtitles = useCallback(() => {
-    drawFocus()
-    setSubtitlesEnabled(!subtitlesEnabled)
-  }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
-
-  const onPressMute = useCallback(() => {
-    drawFocus()
-    toggleMute()
-  }, [drawFocus, toggleMute])
-
-  const onPressFullscreen = useCallback(() => {
-    drawFocus()
-    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 [showCursor, setShowCursor] = useState(true)
-  const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
-  const onPointerMoveEmptySpace = useCallback(() => {
-    setShowCursor(true)
-    if (cursorTimeoutRef.current) {
-      clearTimeout(cursorTimeoutRef.current)
-    }
-    cursorTimeoutRef.current = setTimeout(() => {
-      setShowCursor(false)
-      onEndHover()
-    }, 2000)
-  }, [onEndHover])
-  const onPointerLeaveEmptySpace = useCallback(() => {
-    setShowCursor(false)
-    if (cursorTimeoutRef.current) {
-      clearTimeout(cursorTimeoutRef.current)
-    }
-  }, [])
-
-  // these are used to trigger the hover state. on mobile, the hover state
-  // should stick around for a bit after they tap, and if the controls aren't
-  // present this initial tab should *only* show the controls and not activate anything
-
-  const onPointerDown = useCallback(
-    (evt: React.PointerEvent<HTMLDivElement>) => {
-      if (evt.pointerType !== 'mouse' && !hovered) {
-        evt.preventDefault()
-      }
-      clearTimeout(timeoutRef.current)
-    },
-    [hovered],
-  )
-
-  const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
-
-  const onHoverWithTimeout = useCallback(() => {
-    onHover()
-    clearTimeout(timeoutRef.current)
-  }, [onHover])
-
-  const onEndHoverWithTimeout = useCallback(
-    (evt: React.PointerEvent<HTMLDivElement>) => {
-      // if touch, end after 3s
-      // if mouse, end immediately
-      if (evt.pointerType !== 'mouse') {
-        setTimeout(onEndHover, 3000)
-      } else {
-        onEndHover()
-      }
-    },
-    [onEndHover],
-  )
-
-  const showControls =
-    ((focused || autoplayDisabled) && !playing) ||
-    (interactingViaKeypress ? hasFocus : hovered)
-
-  return (
-    <div
-      style={{
-        position: 'absolute',
-        inset: 0,
-        overflow: 'hidden',
-        display: 'flex',
-        flexDirection: 'column',
-      }}
-      onClick={evt => {
-        evt.stopPropagation()
-        setInteractingViaKeypress(false)
-      }}
-      onPointerEnter={onHoverWithTimeout}
-      onPointerMove={onHoverWithTimeout}
-      onPointerLeave={onEndHoverWithTimeout}
-      onPointerDown={onPointerDown}
-      onFocus={onFocus}
-      onBlur={onBlur}
-      onKeyDown={onKeyDown}>
-      <Pressable
-        accessibilityRole="button"
-        onPointerEnter={onPointerMoveEmptySpace}
-        onPointerMove={onPointerMoveEmptySpace}
-        onPointerLeave={onPointerLeaveEmptySpace}
-        accessibilityHint={_(
-          !focused
-            ? msg`Unmute video`
-            : playing
-            ? msg`Pause video`
-            : msg`Play video`,
-        )}
-        style={[
-          a.flex_1,
-          web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
-        ]}
-        onPress={onPressEmptySpace}
-      />
-      {!showControls && !focused && duration > 0 && (
-        <TimeIndicator time={Math.floor(duration - currentTime)} />
-      )}
-      <View
-        style={[
-          a.flex_shrink_0,
-          a.w_full,
-          a.px_xs,
-          web({
-            background:
-              'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
-          }),
-          {opacity: showControls ? 1 : 0},
-          {transition: 'opacity 0.2s ease-in-out'},
-        ]}>
-        <Scrubber
-          duration={duration}
-          currentTime={currentTime}
-          onSeek={onSeek}
-          onSeekStart={onSeekStart}
-          onSeekEnd={onSeekEnd}
-          seekLeft={seekLeft}
-          seekRight={seekRight}
-          togglePlayPause={togglePlayPause}
-          drawFocus={drawFocus}
-        />
-        <View
-          style={[
-            a.flex_1,
-            a.px_xs,
-            a.pt_2xs,
-            a.pb_md,
-            a.gap_md,
-            a.flex_row,
-            a.align_center,
-          ]}>
-          <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, fontVariant: ['tabular-nums']}}>
-            {formatTime(currentTime)} / {formatTime(duration)}
-          </Text>
-          {hasSubtitleTrack && (
-            <ControlButton
-              active={subtitlesEnabled}
-              activeLabel={_(msg`Disable subtitles`)}
-              inactiveLabel={_(msg`Enable subtitles`)}
-              activeIcon={CCActiveIcon}
-              inactiveIcon={CCInactiveIcon}
-              onPress={onPressSubtitles}
-            />
-          )}
-          <ControlButton
-            active={muted}
-            activeLabel={_(msg({message: `Unmute`, context: 'video'}))}
-            inactiveLabel={_(msg({message: `Mute`, context: 'video'}))}
-            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) && (
-        <View
-          pointerEvents="none"
-          style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
-          {buffering && <Loader fill={t.palette.white} size="lg" />}
-          {error && (
-            <Text style={{color: t.palette.white}}>
-              <Trans>An error occurred</Trans>
-            </Text>
-          )}
-        </View>
-      )}
-    </div>
-  )
-}
-
-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: 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: 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},
-          ]}>
-          {duration > 0 && (
-            <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)) {
-    return '--'
-  }
-
-  time = Math.round(time)
-
-  const minutes = Math.floor(time / 60)
-  const seconds = String(time % 60).padStart(2, '0')
-
-  return `${minutes}:${seconds}`
-}
-
-function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
-  const [playing, setPlaying] = useState(false)
-  const [muted, setMuted] = useState(true)
-  const [currentTime, setCurrentTime] = useState(0)
-  const [duration, setDuration] = useState(0)
-  const [buffering, setBuffering] = useState(false)
-  const [error, setError] = useState(false)
-  const [canPlay, setCanPlay] = useState(false)
-  const playWhenReadyRef = useRef(false)
-
-  useEffect(() => {
-    if (!ref.current) return
-
-    let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
-
-    function round(num: number) {
-      return Math.round(num * 100) / 100
-    }
-
-    // Initial values
-    setCurrentTime(round(ref.current.currentTime) || 0)
-    setDuration(round(ref.current.duration) || 0)
-    setMuted(ref.current.muted)
-    setPlaying(!ref.current.paused)
-
-    const handleTimeUpdate = () => {
-      if (!ref.current) return
-      setCurrentTime(round(ref.current.currentTime) || 0)
-    }
-
-    const handleDurationChange = () => {
-      if (!ref.current) return
-      setDuration(round(ref.current.duration) || 0)
-    }
-
-    const handlePlay = () => {
-      setPlaying(true)
-    }
-
-    const handlePause = () => {
-      setPlaying(false)
-    }
-
-    const handleVolumeChange = () => {
-      if (!ref.current) return
-      setMuted(ref.current.muted)
-    }
-
-    const handleError = () => {
-      setError(true)
-    }
-
-    const handleCanPlay = () => {
-      setBuffering(false)
-      setCanPlay(true)
-
-      if (!ref.current) return
-      if (playWhenReadyRef.current) {
-        ref.current.play()
-        playWhenReadyRef.current = false
-      }
-    }
-
-    const handleCanPlayThrough = () => {
-      setBuffering(false)
-    }
-
-    const handleWaiting = () => {
-      if (bufferingTimeout) clearTimeout(bufferingTimeout)
-      bufferingTimeout = setTimeout(() => {
-        setBuffering(true)
-      }, 200) // Delay to avoid frequent buffering state changes
-    }
-
-    const handlePlaying = () => {
-      if (bufferingTimeout) clearTimeout(bufferingTimeout)
-      setBuffering(false)
-      setError(false)
-    }
-
-    const handleStalled = () => {
-      if (bufferingTimeout) clearTimeout(bufferingTimeout)
-      bufferingTimeout = setTimeout(() => {
-        setBuffering(true)
-      }, 200) // Delay to avoid frequent buffering state changes
-    }
-
-    const handleEnded = () => {
-      setPlaying(false)
-      setBuffering(false)
-      setError(false)
-    }
-
-    const abortController = new AbortController()
-
-    ref.current.addEventListener('timeupdate', handleTimeUpdate, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('durationchange', handleDurationChange, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('play', handlePlay, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('pause', handlePause, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('volumechange', handleVolumeChange, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('error', handleError, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('canplay', handleCanPlay, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('waiting', handleWaiting, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('playing', handlePlaying, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('stalled', handleStalled, {
-      signal: abortController.signal,
-    })
-    ref.current.addEventListener('ended', handleEnded, {
-      signal: abortController.signal,
-    })
-
-    return () => {
-      abortController.abort()
-      clearTimeout(bufferingTimeout)
-    }
-  }, [ref])
-
-  const play = useCallback(() => {
-    if (!ref.current) return
-
-    if (ref.current.ended) {
-      ref.current.currentTime = 0
-    }
-
-    if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
-      playWhenReadyRef.current = true
-    } else {
-      const promise = ref.current.play()
-      if (promise !== undefined) {
-        promise.catch(err => {
-          console.error('Error playing video:', err)
-        })
-      }
-    }
-  }, [ref])
-
-  const pause = useCallback(() => {
-    if (!ref.current) return
-
-    ref.current.pause()
-    playWhenReadyRef.current = false
-  }, [ref])
-
-  const togglePlayPause = useCallback(() => {
-    if (!ref.current) return
-
-    if (ref.current.paused) {
-      play()
-    } else {
-      pause()
-    }
-  }, [ref, play, pause])
-
-  const mute = useCallback(() => {
-    if (!ref.current) return
-
-    ref.current.muted = true
-  }, [ref])
-
-  const unmute = useCallback(() => {
-    if (!ref.current) return
-
-    ref.current.muted = false
-  }, [ref])
-
-  const toggleMute = useCallback(() => {
-    if (!ref.current) return
-
-    ref.current.muted = !ref.current.muted
-  }, [ref])
-
-  return {
-    play,
-    pause,
-    togglePlayPause,
-    duration,
-    currentTime,
-    playing,
-    muted,
-    mute,
-    unmute,
-    toggleMute,
-    buffering,
-    error,
-    canPlay,
-  }
-}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
new file mode 100644
index 000000000..36b32a072
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import {SvgProps} from 'react-native-svg'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+
+export 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>
+  )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx
new file mode 100644
index 000000000..84b667053
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx
@@ -0,0 +1,231 @@
+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} from '#/lib/browser'
+import {clamp} from '#/lib/numbers'
+import {atoms as a, useTheme} 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: 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},
+          ]}>
+          {duration > 0 && (
+            <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>
+  )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx
index e2e24ed36..e2e24ed36 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
new file mode 100644
index 000000000..5bd7e0d17
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
@@ -0,0 +1,423 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react'
+import {Pressable, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import type Hls from 'hls.js'
+
+import {isTouchDevice} from '#/lib/browser'
+import {clamp} from '#/lib/numbers'
+import {isIPhoneWeb} from '#/platform/detection'
+import {
+  useAutoplayDisabled,
+  useSetSubtitlesEnabled,
+  useSubtitlesEnabled,
+} from '#/state/preferences'
+import {atoms as a, useTheme, web} from '#/alf'
+import {useIsWithinMessage} from '#/components/dms/MessageContext'
+import {useFullscreen} from '#/components/hooks/useFullscreen'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {
+  ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
+  ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
+} from '#/components/icons/ArrowsDiagonal'
+import {
+  CC_Filled_Corner0_Rounded as CCActiveIcon,
+  CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
+} from '#/components/icons/CC'
+import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
+import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {TimeIndicator} from '../TimeIndicator'
+import {ControlButton} from './ControlButton'
+import {Scrubber} from './Scrubber'
+import {formatTime, useVideoElement} from './utils'
+import {VolumeControl} from './VolumeControl'
+
+export function Controls({
+  videoRef,
+  hlsRef,
+  active,
+  setActive,
+  focused,
+  setFocused,
+  onScreen,
+  fullscreenRef,
+  hasSubtitleTrack,
+}: {
+  videoRef: React.RefObject<HTMLVideoElement>
+  hlsRef: React.RefObject<Hls | undefined>
+  active: boolean
+  setActive: () => void
+  focused: boolean
+  setFocused: (focused: boolean) => void
+  onScreen: boolean
+  fullscreenRef: React.RefObject<HTMLDivElement>
+  hasSubtitleTrack: boolean
+}) {
+  const {
+    play,
+    pause,
+    playing,
+    muted,
+    changeMuted,
+    togglePlayPause,
+    currentTime,
+    duration,
+    buffering,
+    error,
+    canPlay,
+  } = useVideoElement(videoRef)
+  const t = useTheme()
+  const {_} = useLingui()
+  const subtitlesEnabled = useSubtitlesEnabled()
+  const setSubtitlesEnabled = useSetSubtitlesEnabled()
+  const {
+    state: hovered,
+    onIn: onHover,
+    onOut: onEndHover,
+  } = useInteractionState()
+  const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
+  const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
+  const {
+    state: volumeHovered,
+    onIn: onVolumeHover,
+    onOut: onVolumeEndHover,
+  } = useInteractionState()
+
+  const onKeyDown = useCallback(() => {
+    setInteractingViaKeypress(true)
+  }, [])
+
+  useEffect(() => {
+    if (interactingViaKeypress) {
+      document.addEventListener('click', () => setInteractingViaKeypress(false))
+      return () => {
+        document.removeEventListener('click', () =>
+          setInteractingViaKeypress(false),
+        )
+      }
+    }
+  }, [interactingViaKeypress])
+
+  useEffect(() => {
+    if (isFullscreen) {
+      document.documentElement.style.scrollbarGutter = 'unset'
+      return () => {
+        document.documentElement.style.removeProperty('scrollbar-gutter')
+      }
+    }
+  }, [isFullscreen])
+
+  // pause + unfocus when another video is active
+  useEffect(() => {
+    if (!active) {
+      pause()
+      setFocused(false)
+    }
+  }, [active, pause, setFocused])
+
+  // autoplay/pause based on visibility
+  const isWithinMessage = useIsWithinMessage()
+  const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
+  useEffect(() => {
+    if (active) {
+      if (onScreen) {
+        if (!autoplayDisabled) play()
+      } else {
+        pause()
+      }
+    }
+  }, [onScreen, pause, active, play, autoplayDisabled])
+
+  // use minimal quality when not focused
+  useEffect(() => {
+    if (!hlsRef.current) return
+    if (focused) {
+      // auto decide quality based on network conditions
+      hlsRef.current.autoLevelCapping = -1
+      // allow 30s of buffering
+      hlsRef.current.config.maxMaxBufferLength = 30
+    } else {
+      // back to what we initially set
+      hlsRef.current.autoLevelCapping = 0
+      hlsRef.current.config.maxMaxBufferLength = 10
+    }
+  }, [hlsRef, focused])
+
+  useEffect(() => {
+    if (!hlsRef.current) return
+    if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
+      hlsRef.current.subtitleTrack = 0
+    } else {
+      hlsRef.current.subtitleTrack = -1
+    }
+  }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
+
+  // clicking on any button should focus the player, if it's not already focused
+  const drawFocus = useCallback(() => {
+    if (!active) {
+      setActive()
+    }
+    setFocused(true)
+  }, [active, setActive, setFocused])
+
+  const onPressEmptySpace = useCallback(() => {
+    if (!focused) {
+      drawFocus()
+      if (autoplayDisabled) play()
+    } else {
+      togglePlayPause()
+    }
+  }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
+
+  const onPressPlayPause = useCallback(() => {
+    drawFocus()
+    togglePlayPause()
+  }, [drawFocus, togglePlayPause])
+
+  const onPressSubtitles = useCallback(() => {
+    drawFocus()
+    setSubtitlesEnabled(!subtitlesEnabled)
+  }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
+
+  const onPressFullscreen = useCallback(() => {
+    drawFocus()
+    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 [showCursor, setShowCursor] = useState(true)
+  const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
+  const onPointerMoveEmptySpace = useCallback(() => {
+    setShowCursor(true)
+    if (cursorTimeoutRef.current) {
+      clearTimeout(cursorTimeoutRef.current)
+    }
+    cursorTimeoutRef.current = setTimeout(() => {
+      setShowCursor(false)
+      onEndHover()
+    }, 2000)
+  }, [onEndHover])
+  const onPointerLeaveEmptySpace = useCallback(() => {
+    setShowCursor(false)
+    if (cursorTimeoutRef.current) {
+      clearTimeout(cursorTimeoutRef.current)
+    }
+  }, [])
+
+  // these are used to trigger the hover state. on mobile, the hover state
+  // should stick around for a bit after they tap, and if the controls aren't
+  // present this initial tab should *only* show the controls and not activate anything
+
+  const onPointerDown = useCallback(
+    (evt: React.PointerEvent<HTMLDivElement>) => {
+      if (evt.pointerType !== 'mouse' && !hovered) {
+        evt.preventDefault()
+      }
+      clearTimeout(timeoutRef.current)
+    },
+    [hovered],
+  )
+
+  const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
+
+  const onHoverWithTimeout = useCallback(() => {
+    onHover()
+    clearTimeout(timeoutRef.current)
+  }, [onHover])
+
+  const onEndHoverWithTimeout = useCallback(
+    (evt: React.PointerEvent<HTMLDivElement>) => {
+      // if touch, end after 3s
+      // if mouse, end immediately
+      if (evt.pointerType !== 'mouse') {
+        setTimeout(onEndHover, 3000)
+      } else {
+        onEndHover()
+      }
+    },
+    [onEndHover],
+  )
+
+  const showControls =
+    ((focused || autoplayDisabled) && !playing) ||
+    (interactingViaKeypress ? hasFocus : hovered)
+
+  return (
+    <div
+      style={{
+        position: 'absolute',
+        inset: 0,
+        overflow: 'hidden',
+        display: 'flex',
+        flexDirection: 'column',
+      }}
+      onClick={evt => {
+        evt.stopPropagation()
+        setInteractingViaKeypress(false)
+      }}
+      onPointerEnter={onHoverWithTimeout}
+      onPointerMove={onHoverWithTimeout}
+      onPointerLeave={onEndHoverWithTimeout}
+      onPointerDown={onPointerDown}
+      onFocus={onFocus}
+      onBlur={onBlur}
+      onKeyDown={onKeyDown}>
+      <Pressable
+        accessibilityRole="button"
+        onPointerEnter={onPointerMoveEmptySpace}
+        onPointerMove={onPointerMoveEmptySpace}
+        onPointerLeave={onPointerLeaveEmptySpace}
+        accessibilityHint={_(
+          !focused
+            ? msg`Unmute video`
+            : playing
+            ? msg`Pause video`
+            : msg`Play video`,
+        )}
+        style={[
+          a.flex_1,
+          web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
+        ]}
+        onPress={onPressEmptySpace}
+      />
+      {!showControls && !focused && duration > 0 && (
+        <TimeIndicator time={Math.floor(duration - currentTime)} />
+      )}
+      <View
+        style={[
+          a.flex_shrink_0,
+          a.w_full,
+          a.px_xs,
+          web({
+            background:
+              'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
+          }),
+          {opacity: showControls ? 1 : 0},
+          {transition: 'opacity 0.2s ease-in-out'},
+        ]}>
+        {(!volumeHovered || isTouchDevice) && (
+          <Scrubber
+            duration={duration}
+            currentTime={currentTime}
+            onSeek={onSeek}
+            onSeekStart={onSeekStart}
+            onSeekEnd={onSeekEnd}
+            seekLeft={seekLeft}
+            seekRight={seekRight}
+            togglePlayPause={togglePlayPause}
+            drawFocus={drawFocus}
+          />
+        )}
+        <View
+          style={[
+            a.flex_1,
+            a.px_xs,
+            a.pt_2xs,
+            a.pb_md,
+            a.gap_md,
+            a.flex_row,
+            a.align_center,
+          ]}>
+          <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, fontVariant: ['tabular-nums']}}>
+            {formatTime(currentTime)} / {formatTime(duration)}
+          </Text>
+          {hasSubtitleTrack && (
+            <ControlButton
+              active={subtitlesEnabled}
+              activeLabel={_(msg`Disable subtitles`)}
+              inactiveLabel={_(msg`Enable subtitles`)}
+              activeIcon={CCActiveIcon}
+              inactiveIcon={CCInactiveIcon}
+              onPress={onPressSubtitles}
+            />
+          )}
+          <VolumeControl
+            muted={muted}
+            changeMuted={changeMuted}
+            hovered={volumeHovered}
+            onHover={onVolumeHover}
+            onEndHover={onVolumeEndHover}
+            drawFocus={drawFocus}
+          />
+          {!isIPhoneWeb && (
+            <ControlButton
+              active={isFullscreen}
+              activeLabel={_(msg`Exit fullscreen`)}
+              inactiveLabel={_(msg`Fullscreen`)}
+              activeIcon={ArrowsInIcon}
+              inactiveIcon={ArrowsOutIcon}
+              onPress={onPressFullscreen}
+            />
+          )}
+        </View>
+      </View>
+      {(buffering || error) && (
+        <View
+          pointerEvents="none"
+          style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+          {buffering && <Loader fill={t.palette.white} size="lg" />}
+          {error && (
+            <Text style={{color: t.palette.white}}>
+              <Trans>An error occurred</Trans>
+            </Text>
+          )}
+        </View>
+      )}
+    </div>
+  )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx
new file mode 100644
index 000000000..63ac32b10
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx
@@ -0,0 +1,109 @@
+import React, {useCallback} from 'react'
+import {View} from 'react-native'
+import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isSafari, isTouchDevice} from '#/lib/browser'
+import {atoms as a} from '#/alf'
+import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
+import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
+import {useVideoVolumeState} from '../../VideoVolumeContext'
+import {ControlButton} from './ControlButton'
+
+export function VolumeControl({
+  muted,
+  changeMuted,
+  hovered,
+  onHover,
+  onEndHover,
+  drawFocus,
+}: {
+  muted: boolean
+  changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void
+  hovered: boolean
+  onHover: () => void
+  onEndHover: () => void
+  drawFocus: () => void
+}) {
+  const {_} = useLingui()
+  const [volume, setVolume] = useVideoVolumeState()
+
+  const onVolumeChange = useCallback(
+    (evt: React.ChangeEvent<HTMLInputElement>) => {
+      drawFocus()
+      const vol = sliderVolumeToVideoVolume(Number(evt.target.value))
+      setVolume(vol)
+      changeMuted(vol === 0)
+    },
+    [setVolume, drawFocus, changeMuted],
+  )
+
+  const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume)
+
+  const isZeroVolume = volume === 0
+  const onPressMute = useCallback(() => {
+    drawFocus()
+    if (isZeroVolume) {
+      setVolume(1)
+      changeMuted(false)
+    } else {
+      changeMuted(prevMuted => !prevMuted)
+    }
+  }, [drawFocus, setVolume, isZeroVolume, changeMuted])
+
+  return (
+    <View
+      onPointerEnter={onHover}
+      onPointerLeave={onEndHover}
+      style={[a.relative]}>
+      {hovered && !isTouchDevice && (
+        <Animated.View
+          entering={FadeIn.duration(100)}
+          exiting={FadeOut.duration(100)}
+          style={[a.absolute, a.w_full, {height: 100, bottom: '100%'}]}>
+          <View
+            style={[
+              a.flex_1,
+              a.mb_xs,
+              a.px_2xs,
+              a.py_xs,
+              {backgroundColor: 'rgba(0, 0, 0, 0.6)'},
+              a.rounded_xs,
+              a.align_center,
+            ]}>
+            <input
+              type="range"
+              min={0}
+              max={100}
+              value={sliderVolume}
+              style={
+                // Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h
+                isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'}
+              }
+              onChange={onVolumeChange}
+              // @ts-expect-error for old versions of firefox, and then re-using it for targeting the CSS -sfn
+              orient="vertical"
+            />
+          </View>
+        </Animated.View>
+      )}
+      <ControlButton
+        active={muted || volume === 0}
+        activeLabel={_(msg({message: `Unmute`, context: 'video'}))}
+        inactiveLabel={_(msg({message: `Mute`, context: 'video'}))}
+        activeIcon={MuteIcon}
+        inactiveIcon={UnmuteIcon}
+        onPress={onPressMute}
+      />
+    </View>
+  )
+}
+
+function sliderVolumeToVideoVolume(value: number) {
+  return Math.pow(value / 100, 4)
+}
+
+function videoVolumeToSliderVolume(value: number) {
+  return Math.round(Math.pow(value, 1 / 4) * 100)
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx
new file mode 100644
index 000000000..aa1b0b8cd
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx
@@ -0,0 +1,228 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react'
+
+import {useVideoVolumeState} from '../../VideoVolumeContext'
+
+export function useVideoElement(ref: React.RefObject<HTMLVideoElement>) {
+  const [playing, setPlaying] = useState(false)
+  const [muted, setMuted] = useState(true)
+  const [currentTime, setCurrentTime] = useState(0)
+  const [volume, setVolume] = useVideoVolumeState()
+  const [duration, setDuration] = useState(0)
+  const [buffering, setBuffering] = useState(false)
+  const [error, setError] = useState(false)
+  const [canPlay, setCanPlay] = useState(false)
+  const playWhenReadyRef = useRef(false)
+
+  useEffect(() => {
+    if (!ref.current) return
+    ref.current.volume = volume
+  }, [ref, volume])
+
+  useEffect(() => {
+    if (!ref.current) return
+
+    let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
+
+    function round(num: number) {
+      return Math.round(num * 100) / 100
+    }
+
+    // Initial values
+    setCurrentTime(round(ref.current.currentTime) || 0)
+    setDuration(round(ref.current.duration) || 0)
+    setMuted(ref.current.muted)
+    setPlaying(!ref.current.paused)
+    setVolume(ref.current.volume)
+
+    const handleTimeUpdate = () => {
+      if (!ref.current) return
+      setCurrentTime(round(ref.current.currentTime) || 0)
+    }
+
+    const handleDurationChange = () => {
+      if (!ref.current) return
+      setDuration(round(ref.current.duration) || 0)
+    }
+
+    const handlePlay = () => {
+      setPlaying(true)
+    }
+
+    const handlePause = () => {
+      setPlaying(false)
+    }
+
+    const handleVolumeChange = () => {
+      if (!ref.current) return
+      setMuted(ref.current.muted)
+    }
+
+    const handleError = () => {
+      setError(true)
+    }
+
+    const handleCanPlay = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      setBuffering(false)
+      setCanPlay(true)
+
+      if (!ref.current) return
+      if (playWhenReadyRef.current) {
+        ref.current.play()
+        playWhenReadyRef.current = false
+      }
+    }
+
+    const handleCanPlayThrough = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      setBuffering(false)
+    }
+
+    const handleWaiting = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      bufferingTimeout = setTimeout(() => {
+        setBuffering(true)
+      }, 200) // Delay to avoid frequent buffering state changes
+    }
+
+    const handlePlaying = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      setBuffering(false)
+      setError(false)
+    }
+
+    const handleStalled = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      bufferingTimeout = setTimeout(() => {
+        setBuffering(true)
+      }, 200) // Delay to avoid frequent buffering state changes
+    }
+
+    const handleEnded = () => {
+      setPlaying(false)
+      setBuffering(false)
+      setError(false)
+    }
+
+    const abortController = new AbortController()
+
+    ref.current.addEventListener('timeupdate', handleTimeUpdate, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('durationchange', handleDurationChange, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('play', handlePlay, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('pause', handlePause, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('volumechange', handleVolumeChange, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('error', handleError, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('canplay', handleCanPlay, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('waiting', handleWaiting, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('playing', handlePlaying, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('stalled', handleStalled, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('ended', handleEnded, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('volumechange', handleVolumeChange, {
+      signal: abortController.signal,
+    })
+
+    return () => {
+      abortController.abort()
+      clearTimeout(bufferingTimeout)
+    }
+  }, [ref, setVolume])
+
+  const play = useCallback(() => {
+    if (!ref.current) return
+
+    if (ref.current.ended) {
+      ref.current.currentTime = 0
+    }
+
+    if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
+      playWhenReadyRef.current = true
+    } else {
+      const promise = ref.current.play()
+      if (promise !== undefined) {
+        promise.catch(err => {
+          console.error('Error playing video:', err)
+        })
+      }
+    }
+  }, [ref])
+
+  const pause = useCallback(() => {
+    if (!ref.current) return
+
+    ref.current.pause()
+    playWhenReadyRef.current = false
+  }, [ref])
+
+  const togglePlayPause = useCallback(() => {
+    if (!ref.current) return
+
+    if (ref.current.paused) {
+      play()
+    } else {
+      pause()
+    }
+  }, [ref, play, pause])
+
+  const changeMuted = useCallback(
+    (newMuted: boolean | ((prev: boolean) => boolean)) => {
+      if (!ref.current) return
+
+      const value =
+        typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted
+      ref.current.muted = value
+    },
+    [ref],
+  )
+
+  return {
+    play,
+    pause,
+    togglePlayPause,
+    duration,
+    currentTime,
+    playing,
+    muted,
+    changeMuted,
+    buffering,
+    error,
+    canPlay,
+  }
+}
+
+export function formatTime(time: number) {
+  if (isNaN(time)) {
+    return '--'
+  }
+
+  time = Math.round(time)
+
+  const minutes = Math.floor(time / 60)
+  const seconds = String(time % 60).padStart(2, '0')
+
+  return `${minutes}:${seconds}`
+}
diff --git a/src/view/com/util/post-embeds/VideoVolumeContext.tsx b/src/view/com/util/post-embeds/VideoVolumeContext.tsx
index cccb93ba8..6343081da 100644
--- a/src/view/com/util/post-embeds/VideoVolumeContext.tsx
+++ b/src/view/com/util/post-embeds/VideoVolumeContext.tsx
@@ -1,21 +1,26 @@
 import React from 'react'
 
-const Context = React.createContext(
-  {} as {
-    muted: boolean
-    setMuted: (muted: boolean) => void
-  },
-)
+const Context = React.createContext<{
+  // native
+  muted: boolean
+  setMuted: React.Dispatch<React.SetStateAction<boolean>>
+  // web
+  volume: number
+  setVolume: React.Dispatch<React.SetStateAction<number>>
+} | null>(null)
 
 export function Provider({children}: {children: React.ReactNode}) {
   const [muted, setMuted] = React.useState(true)
+  const [volume, setVolume] = React.useState(1)
 
   const value = React.useMemo(
     () => ({
       muted,
       setMuted,
+      volume,
+      setVolume,
     }),
-    [muted, setMuted],
+    [muted, setMuted, volume, setVolume],
   )
 
   return <Context.Provider value={value}>{children}</Context.Provider>
@@ -28,5 +33,15 @@ export function useVideoVolumeState() {
       'useVideoVolumeState must be used within a VideoVolumeProvider',
     )
   }
-  return context
+  return [context.volume, context.setVolume] as const
+}
+
+export function useVideoMuteState() {
+  const context = React.useContext(Context)
+  if (!context) {
+    throw new Error(
+      'useVideoMuteState must be used within a VideoVolumeProvider',
+    )
+  }
+  return [context.muted, context.setMuted] as const
 }