about summary refs log tree commit diff
path: root/src/components/Post/Embed
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
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')
-rw-r--r--src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx147
-rw-r--r--src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx281
-rw-r--r--src/components/Post/Embed/ExternalEmbed/Gif.tsx224
-rw-r--r--src/components/Post/Embed/ExternalEmbed/index.tsx182
-rw-r--r--src/components/Post/Embed/FeedEmbed.tsx52
-rw-r--r--src/components/Post/Embed/ImageEmbed.tsx106
-rw-r--r--src/components/Post/Embed/LazyQuoteEmbed.tsx37
-rw-r--r--src/components/Post/Embed/ListEmbed.tsx42
-rw-r--r--src/components/Post/Embed/PostPlaceholder.tsx33
-rw-r--r--src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx114
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx64
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx210
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx3
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx3
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx307
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx61
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts11
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx42
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx238
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx3
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx427
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx110
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx240
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx47
-rw-r--r--src/components/Post/Embed/VideoEmbed/index.tsx167
-rw-r--r--src/components/Post/Embed/VideoEmbed/index.web.tsx207
-rw-r--r--src/components/Post/Embed/index.tsx332
-rw-r--r--src/components/Post/Embed/types.ts25
28 files changed, 3715 insertions, 0 deletions
diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx
new file mode 100644
index 000000000..8a12f0374
--- /dev/null
+++ b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx
@@ -0,0 +1,147 @@
+import React from 'react'
+import {ActivityIndicator, GestureResponderEvent, Pressable} from 'react-native'
+import {Image} from 'expo-image'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {EmbedPlayerParams} from '#/lib/strings/embed-player'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {useExternalEmbedsPrefs} from '#/state/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
+import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
+import {Fill} from '#/components/Fill'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
+
+export function ExternalGif({
+  link,
+  params,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+}) {
+  const t = useTheme()
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const {_} = useLingui()
+  const consentDialogControl = useDialogControl()
+
+  // Tracking if the placer has been activated
+  const [isPlayerActive, setIsPlayerActive] = React.useState(false)
+  // Tracking whether the gif has been loaded yet
+  const [isPrefetched, setIsPrefetched] = React.useState(false)
+  // Tracking whether the image is animating
+  const [isAnimating, setIsAnimating] = React.useState(true)
+
+  // Used for controlling animation
+  const imageRef = React.useRef<Image>(null)
+
+  const load = React.useCallback(() => {
+    setIsPlayerActive(true)
+    Image.prefetch(params.playerUri).then(() => {
+      // Replace the image once it's fetched
+      setIsPrefetched(true)
+    })
+  }, [params.playerUri])
+
+  const onPlayPress = React.useCallback(
+    (event: GestureResponderEvent) => {
+      // Don't propagate on web
+      event.preventDefault()
+
+      // Show consent if this is the first load
+      if (externalEmbedsPrefs?.[params.source] === undefined) {
+        consentDialogControl.open()
+        return
+      }
+      // If the player isn't active, we want to activate it and prefetch the gif
+      if (!isPlayerActive) {
+        load()
+        return
+      }
+      // Control animation on native
+      setIsAnimating(prev => {
+        if (prev) {
+          if (isNative) {
+            imageRef.current?.stopAnimating()
+          }
+          return false
+        } else {
+          if (isNative) {
+            imageRef.current?.startAnimating()
+          }
+          return true
+        }
+      })
+    },
+    [
+      consentDialogControl,
+      externalEmbedsPrefs,
+      isPlayerActive,
+      load,
+      params.source,
+    ],
+  )
+
+  return (
+    <>
+      <EmbedConsentDialog
+        control={consentDialogControl}
+        source={params.source}
+        onAccept={load}
+      />
+
+      <Pressable
+        style={[
+          {height: 300},
+          a.w_full,
+          a.overflow_hidden,
+          {
+            borderBottomLeftRadius: 0,
+            borderBottomRightRadius: 0,
+          },
+        ]}
+        onPress={onPlayPress}
+        accessibilityRole="button"
+        accessibilityHint={_(msg`Plays the GIF`)}
+        accessibilityLabel={_(msg`Play ${link.title}`)}>
+        <Image
+          source={{
+            uri:
+              !isPrefetched || (isWeb && !isAnimating)
+                ? link.thumb
+                : params.playerUri,
+          }} // Web uses the thumb to control playback
+          style={{flex: 1}}
+          ref={imageRef}
+          autoplay={isAnimating}
+          contentFit="contain"
+          accessibilityIgnoresInvertColors
+          accessibilityLabel={link.title}
+          accessibilityHint={link.title}
+          cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
+        />
+
+        {(!isPrefetched || !isAnimating) && (
+          <Fill style={[a.align_center, a.justify_center]}>
+            <Fill
+              style={[
+                t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
+                {
+                  opacity: 0.3,
+                },
+              ]}
+            />
+
+            {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
+              <PlayButtonIcon />
+            ) : (
+              // Activity indicator while gif loads
+              <ActivityIndicator size="large" color="white" />
+            )}
+          </Fill>
+        )}
+      </Pressable>
+    </>
+  )
+}
diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
new file mode 100644
index 000000000..7f6d53340
--- /dev/null
+++ b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
@@ -0,0 +1,281 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  GestureResponderEvent,
+  Pressable,
+  StyleSheet,
+  useWindowDimensions,
+  View,
+} from 'react-native'
+import Animated, {
+  measure,
+  runOnJS,
+  useAnimatedRef,
+  useFrameCallback,
+} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {WebView} from 'react-native-webview'
+import {Image} from 'expo-image'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {NavigationProp} from '#/lib/routes/types'
+import {EmbedPlayerParams, getPlayerAspect} from '#/lib/strings/embed-player'
+import {isNative} from '#/platform/detection'
+import {useExternalEmbedsPrefs} from '#/state/preferences'
+import {EventStopper} from '#/view/com/util/EventStopper'
+import {atoms as a, useTheme} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
+import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
+import {Fill} from '#/components/Fill'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
+
+interface ShouldStartLoadRequest {
+  url: string
+}
+
+// This renders the overlay when the player is either inactive or loading as a separate layer
+function PlaceholderOverlay({
+  isLoading,
+  isPlayerActive,
+  onPress,
+}: {
+  isLoading: boolean
+  isPlayerActive: boolean
+  onPress: (event: GestureResponderEvent) => void
+}) {
+  const {_} = useLingui()
+
+  // If the player is active and not loading, we don't want to show the overlay.
+  if (isPlayerActive && !isLoading) return null
+
+  return (
+    <View style={[a.absolute, a.inset_0, styles.overlayLayer]}>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Play Video`)}
+        accessibilityHint={_(msg`Plays the video`)}
+        onPress={onPress}
+        style={[styles.overlayContainer]}>
+        {!isPlayerActive ? (
+          <PlayButtonIcon />
+        ) : (
+          <ActivityIndicator size="large" color="white" />
+        )}
+      </Pressable>
+    </View>
+  )
+}
+
+// This renders the webview/youtube player as a separate layer
+function Player({
+  params,
+  onLoad,
+  isPlayerActive,
+}: {
+  isPlayerActive: boolean
+  params: EmbedPlayerParams
+  onLoad: () => void
+}) {
+  // ensures we only load what's requested
+  // when it's a youtube video, we need to allow both bsky.app and youtube.com
+  const onShouldStartLoadWithRequest = React.useCallback(
+    (event: ShouldStartLoadRequest) =>
+      event.url === params.playerUri ||
+      (params.source.startsWith('youtube') &&
+        event.url.includes('www.youtube.com')),
+    [params.playerUri, params.source],
+  )
+
+  // Don't show the player until it is active
+  if (!isPlayerActive) return null
+
+  return (
+    <EventStopper style={[a.absolute, a.inset_0, styles.playerLayer]}>
+      <WebView
+        javaScriptEnabled={true}
+        onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+        mediaPlaybackRequiresUserAction={false}
+        allowsInlineMediaPlayback
+        bounces={false}
+        allowsFullscreenVideo
+        nestedScrollEnabled
+        source={{uri: params.playerUri}}
+        onLoad={onLoad}
+        style={styles.webview}
+        setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
+      />
+    </EventStopper>
+  )
+}
+
+// This renders the player area and handles the logic for when to show the player and when to show the overlay
+export function ExternalPlayer({
+  link,
+  params,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+}) {
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+  const insets = useSafeAreaInsets()
+  const windowDims = useWindowDimensions()
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const consentDialogControl = useDialogControl()
+
+  const [isPlayerActive, setPlayerActive] = React.useState(false)
+  const [isLoading, setIsLoading] = React.useState(true)
+
+  const aspect = React.useMemo(() => {
+    return getPlayerAspect({
+      type: params.type,
+      width: windowDims.width,
+      hasThumb: !!link.thumb,
+    })
+  }, [params.type, windowDims.width, link.thumb])
+
+  const viewRef = useAnimatedRef()
+  const frameCallback = useFrameCallback(() => {
+    const measurement = measure(viewRef)
+    if (!measurement) return
+
+    const {height: winHeight, width: winWidth} = windowDims
+
+    // Get the proper screen height depending on what is going on
+    const realWinHeight = isNative // If it is native, we always want the larger number
+      ? winHeight > winWidth
+        ? winHeight
+        : winWidth
+      : winHeight // On web, we always want the actual screen height
+
+    const top = measurement.pageY
+    const bot = measurement.pageY + measurement.height
+
+    // We can use the same logic on all platforms against the screenHeight that we get above
+    const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top
+
+    if (!isVisible) {
+      runOnJS(setPlayerActive)(false)
+    }
+  }, false) // False here disables autostarting the callback
+
+  // watch for leaving the viewport due to scrolling
+  React.useEffect(() => {
+    // We don't want to do anything if the player isn't active
+    if (!isPlayerActive) return
+
+    // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
+    // continue playing. We need to watch for the blur event
+    const unsubscribe = navigation.addListener('blur', () => {
+      setPlayerActive(false)
+    })
+
+    // Start watching for changes
+    frameCallback.setActive(true)
+
+    return () => {
+      unsubscribe()
+      frameCallback.setActive(false)
+    }
+  }, [navigation, isPlayerActive, frameCallback])
+
+  const onLoad = React.useCallback(() => {
+    setIsLoading(false)
+  }, [])
+
+  const onPlayPress = React.useCallback(
+    (event: GestureResponderEvent) => {
+      // Prevent this from propagating upward on web
+      event.preventDefault()
+
+      if (externalEmbedsPrefs?.[params.source] === undefined) {
+        consentDialogControl.open()
+        return
+      }
+
+      setPlayerActive(true)
+    },
+    [externalEmbedsPrefs, consentDialogControl, params.source],
+  )
+
+  const onAcceptConsent = React.useCallback(() => {
+    setPlayerActive(true)
+  }, [])
+
+  return (
+    <>
+      <EmbedConsentDialog
+        control={consentDialogControl}
+        source={params.source}
+        onAccept={onAcceptConsent}
+      />
+
+      <Animated.View
+        ref={viewRef}
+        collapsable={false}
+        style={[aspect, a.overflow_hidden]}>
+        {link.thumb && (!isPlayerActive || isLoading) ? (
+          <>
+            <Image
+              style={[a.flex_1]}
+              source={{uri: link.thumb}}
+              accessibilityIgnoresInvertColors
+            />
+            <Fill
+              style={[
+                t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
+                {
+                  opacity: 0.3,
+                },
+              ]}
+            />
+          </>
+        ) : (
+          <Fill
+            style={[
+              {
+                backgroundColor:
+                  t.name === 'light' ? t.palette.contrast_975 : 'black',
+                opacity: 0.3,
+              },
+            ]}
+          />
+        )}
+        <PlaceholderOverlay
+          isLoading={isLoading}
+          isPlayerActive={isPlayerActive}
+          onPress={onPlayPress}
+        />
+        <Player
+          isPlayerActive={isPlayerActive}
+          params={params}
+          onLoad={onLoad}
+        />
+      </Animated.View>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  overlayContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  overlayLayer: {
+    zIndex: 2,
+  },
+  playerLayer: {
+    zIndex: 3,
+  },
+  webview: {
+    backgroundColor: 'transparent',
+  },
+  gifContainer: {
+    width: '100%',
+    overflow: 'hidden',
+  },
+})
diff --git a/src/components/Post/Embed/ExternalEmbed/Gif.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
new file mode 100644
index 000000000..a839294f1
--- /dev/null
+++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
@@ -0,0 +1,224 @@
+import React from 'react'
+import {
+  Pressable,
+  StyleProp,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {HITSLOP_20} from '#/lib/constants'
+import {EmbedPlayerParams} from '#/lib/strings/embed-player'
+import {isWeb} from '#/platform/detection'
+import {useAutoplayDisabled} from '#/state/preferences'
+import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {atoms as a, useTheme} from '#/alf'
+import {Fill} from '#/components/Fill'
+import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
+import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
+import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
+
+function PlaybackControls({
+  onPress,
+  isPlaying,
+  isLoaded,
+}: {
+  onPress: () => void
+  isPlaying: boolean
+  isLoaded: boolean
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  return (
+    <Pressable
+      accessibilityRole="button"
+      accessibilityHint={_(msg`Plays or pauses the GIF`)}
+      accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
+      style={[
+        a.absolute,
+        a.align_center,
+        a.justify_center,
+        !isLoaded && a.border,
+        t.atoms.border_contrast_medium,
+        a.inset_0,
+        a.w_full,
+        a.h_full,
+        {
+          zIndex: 2,
+          backgroundColor: !isLoaded
+            ? t.atoms.bg_contrast_25.backgroundColor
+            : undefined,
+        },
+      ]}
+      onPress={onPress}>
+      {!isLoaded ? (
+        <View>
+          <View style={[a.align_center, a.justify_center]}>
+            <Loader size="xl" />
+          </View>
+        </View>
+      ) : !isPlaying ? (
+        <PlayButtonIcon />
+      ) : undefined}
+    </Pressable>
+  )
+}
+
+export function GifEmbed({
+  params,
+  thumb,
+  altText,
+  isPreferredAltText,
+  hideAlt,
+  style = {width: '100%'},
+}: {
+  params: EmbedPlayerParams
+  thumb: string | undefined
+  altText: string
+  isPreferredAltText: boolean
+  hideAlt?: boolean
+  style?: StyleProp<ViewStyle>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const autoplayDisabled = useAutoplayDisabled()
+
+  const playerRef = React.useRef<GifView>(null)
+
+  const [playerState, setPlayerState] = React.useState<{
+    isPlaying: boolean
+    isLoaded: boolean
+  }>({
+    isPlaying: !autoplayDisabled,
+    isLoaded: false,
+  })
+
+  const onPlayerStateChange = React.useCallback(
+    (e: GifViewStateChangeEvent) => {
+      setPlayerState(e.nativeEvent)
+    },
+    [],
+  )
+
+  const onPress = React.useCallback(() => {
+    playerRef.current?.toggleAsync()
+  }, [])
+
+  return (
+    <View
+      style={[
+        a.rounded_md,
+        a.overflow_hidden,
+        a.border,
+        t.atoms.border_contrast_low,
+        {aspectRatio: params.dimensions!.width / params.dimensions!.height},
+        style,
+      ]}>
+      <View
+        style={[
+          a.absolute,
+          /*
+           * Aspect ratio was being clipped weirdly on web -esb
+           */
+          {
+            top: -2,
+            bottom: -2,
+            left: -2,
+            right: -2,
+          },
+        ]}>
+        <PlaybackControls
+          onPress={onPress}
+          isPlaying={playerState.isPlaying}
+          isLoaded={playerState.isLoaded}
+        />
+        <GifView
+          source={params.playerUri}
+          placeholderSource={thumb}
+          style={[a.flex_1]}
+          autoplay={!autoplayDisabled}
+          onPlayerStateChange={onPlayerStateChange}
+          ref={playerRef}
+          accessibilityHint={_(msg`Animated GIF`)}
+          accessibilityLabel={altText}
+        />
+        {!playerState.isPlaying && (
+          <Fill
+            style={[
+              t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
+              {
+                opacity: 0.3,
+              },
+            ]}
+          />
+        )}
+        {!hideAlt && isPreferredAltText && <AltText text={altText} />}
+      </View>
+    </View>
+  )
+}
+
+function AltText({text}: {text: string}) {
+  const control = Prompt.usePromptControl()
+  const largeAltBadge = useLargeAltBadgeEnabled()
+
+  const {_} = useLingui()
+  return (
+    <>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Show alt text`)}
+        accessibilityHint=""
+        hitSlop={HITSLOP_20}
+        onPress={control.open}
+        style={styles.altContainer}>
+        <Text
+          style={[styles.alt, largeAltBadge && a.text_xs]}
+          accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+      <Prompt.Outer control={control}>
+        <Prompt.TitleText>
+          <Trans>Alt Text</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            onPress={() => control.close()}
+            cta={_(msg`Close`)}
+            color="secondary"
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  altContainer: {
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderRadius: 6,
+    paddingHorizontal: isWeb ? 8 : 6,
+    paddingVertical: isWeb ? 6 : 3,
+    position: 'absolute',
+    // Related to margin/gap hack. This keeps the alt label in the same position
+    // on all platforms
+    right: isWeb ? 8 : 5,
+    bottom: isWeb ? 8 : 5,
+    zIndex: 2,
+  },
+  alt: {
+    color: 'white',
+    fontSize: isWeb ? 10 : 7,
+    fontWeight: '600',
+  },
+})
diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx
new file mode 100644
index 000000000..714eaecd6
--- /dev/null
+++ b/src/components/Post/Embed/ExternalEmbed/index.tsx
@@ -0,0 +1,182 @@
+import React, {useCallback} from 'react'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {Image} from 'expo-image'
+import {type AppBskyEmbedExternal} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
+import {useHaptics} from '#/lib/haptics'
+import {shareUrl} from '#/lib/sharing'
+import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {isNative} from '#/platform/detection'
+import {useExternalEmbedsPrefs} from '#/state/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {Divider} from '#/components/Divider'
+import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {ExternalGif} from './ExternalGif'
+import {ExternalPlayer} from './ExternalPlayer'
+import {GifEmbed} from './Gif'
+
+export const ExternalEmbed = ({
+  link,
+  onOpen,
+  style,
+  hideAlt,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  onOpen?: () => void
+  style?: StyleProp<ViewStyle>
+  hideAlt?: boolean
+}) => {
+  const {_} = useLingui()
+  const t = useTheme()
+  const playHaptic = useHaptics()
+  const externalEmbedPrefs = useExternalEmbedsPrefs()
+  const niceUrl = toNiceDomain(link.uri)
+  const imageUri = link.thumb
+  const embedPlayerParams = React.useMemo(() => {
+    const params = parseEmbedPlayerFromUrl(link.uri)
+
+    if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
+      return params
+    }
+  }, [link.uri, externalEmbedPrefs])
+  const hasMedia = Boolean(imageUri || embedPlayerParams)
+
+  const onPress = useCallback(() => {
+    playHaptic('Light')
+    onOpen?.()
+  }, [playHaptic, onOpen])
+
+  const onShareExternal = useCallback(() => {
+    if (link.uri && isNative) {
+      playHaptic('Heavy')
+      shareUrl(link.uri)
+    }
+  }, [link.uri, playHaptic])
+
+  if (embedPlayerParams?.source === 'tenor') {
+    const parsedAlt = parseAltFromGIFDescription(link.description)
+    return (
+      <View style={style}>
+        <GifEmbed
+          params={embedPlayerParams}
+          thumb={link.thumb}
+          altText={parsedAlt.alt}
+          isPreferredAltText={parsedAlt.isPreferred}
+          hideAlt={hideAlt}
+        />
+      </View>
+    )
+  }
+
+  return (
+    <Link
+      label={link.title || _(msg`Open link to ${niceUrl}`)}
+      to={link.uri}
+      shouldProxy={true}
+      onPress={onPress}
+      onLongPress={onShareExternal}>
+      {({hovered}) => (
+        <View
+          style={[
+            a.transition_color,
+            a.flex_col,
+            a.rounded_md,
+            a.overflow_hidden,
+            a.w_full,
+            a.border,
+            style,
+            hovered
+              ? t.atoms.border_contrast_high
+              : t.atoms.border_contrast_low,
+          ]}>
+          {imageUri && !embedPlayerParams ? (
+            <Image
+              style={{
+                aspectRatio: 1.91,
+              }}
+              source={{uri: imageUri}}
+              accessibilityIgnoresInvertColors
+            />
+          ) : undefined}
+
+          {embedPlayerParams?.isGif ? (
+            <ExternalGif link={link} params={embedPlayerParams} />
+          ) : embedPlayerParams ? (
+            <ExternalPlayer link={link} params={embedPlayerParams} />
+          ) : undefined}
+
+          <View
+            style={[
+              a.flex_1,
+              a.pt_sm,
+              {gap: 3},
+              hasMedia && a.border_t,
+              hovered
+                ? t.atoms.border_contrast_high
+                : t.atoms.border_contrast_low,
+            ]}>
+            <View style={[{gap: 3}, a.pb_xs, a.px_md]}>
+              {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
+                <Text
+                  emoji
+                  numberOfLines={3}
+                  style={[a.text_md, a.font_bold, a.leading_snug]}>
+                  {link.title || link.uri}
+                </Text>
+              )}
+              {link.description ? (
+                <Text
+                  emoji
+                  numberOfLines={link.thumb ? 2 : 4}
+                  style={[a.text_sm, a.leading_snug]}>
+                  {link.description}
+                </Text>
+              ) : undefined}
+            </View>
+            <View style={[a.px_md]}>
+              <Divider />
+              <View
+                style={[
+                  a.flex_row,
+                  a.align_center,
+                  a.gap_2xs,
+                  a.pb_sm,
+                  {
+                    paddingTop: 6, // off menu
+                  },
+                ]}>
+                <Globe
+                  size="xs"
+                  style={[
+                    a.transition_color,
+                    hovered
+                      ? t.atoms.text_contrast_medium
+                      : t.atoms.text_contrast_low,
+                  ]}
+                />
+                <Text
+                  numberOfLines={1}
+                  style={[
+                    a.transition_color,
+                    a.text_xs,
+                    a.leading_snug,
+                    hovered
+                      ? t.atoms.text_contrast_high
+                      : t.atoms.text_contrast_medium,
+                  ]}>
+                  {toNiceDomain(link.uri)}
+                </Text>
+              </View>
+            </View>
+          </View>
+        </View>
+      )}
+    </Link>
+  )
+}
diff --git a/src/components/Post/Embed/FeedEmbed.tsx b/src/components/Post/Embed/FeedEmbed.tsx
new file mode 100644
index 000000000..fad4cd4d8
--- /dev/null
+++ b/src/components/Post/Embed/FeedEmbed.tsx
@@ -0,0 +1,52 @@
+import React from 'react'
+import {StyleSheet} from 'react-native'
+import {moderateFeedGenerator} from '@atproto/api'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {type EmbedType} from '#/types/bsky/post'
+import {type CommonProps} from './types'
+
+export function FeedEmbed({
+  embed,
+}: CommonProps & {
+  embed: EmbedType<'feed'>
+}) {
+  const pal = usePalette('default')
+  return (
+    <FeedSourceCard
+      feedUri={embed.view.uri}
+      style={[pal.view, pal.border, styles.customFeedOuter]}
+      showLikes
+    />
+  )
+}
+
+export function ModeratedFeedEmbed({
+  embed,
+}: CommonProps & {
+  embed: EmbedType<'feed'>
+}) {
+  const moderationOpts = useModerationOpts()
+  const moderation = React.useMemo(() => {
+    return moderationOpts
+      ? moderateFeedGenerator(embed.view, moderationOpts)
+      : undefined
+  }, [embed.view, moderationOpts])
+  return (
+    <ContentHider modui={moderation?.ui('contentList')}>
+      <FeedEmbed embed={embed} />
+    </ContentHider>
+  )
+}
+
+const styles = StyleSheet.create({
+  customFeedOuter: {
+    borderWidth: StyleSheet.hairlineWidth,
+    borderRadius: 8,
+    paddingHorizontal: 12,
+    paddingVertical: 12,
+  },
+})
diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx
new file mode 100644
index 000000000..030d237a0
--- /dev/null
+++ b/src/components/Post/Embed/ImageEmbed.tsx
@@ -0,0 +1,106 @@
+import {InteractionManager, View} from 'react-native'
+import {
+  type AnimatedRef,
+  measure,
+  type MeasuredDimensions,
+  runOnJS,
+  runOnUI,
+} from 'react-native-reanimated'
+import {Image} from 'expo-image'
+
+import {useLightboxControls} from '#/state/lightbox'
+import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
+import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage'
+import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid'
+import {atoms as a} from '#/alf'
+import {PostEmbedViewContext} from '#/components/Post/Embed/types'
+import {type EmbedType} from '#/types/bsky/post'
+import {type CommonProps} from './types'
+
+export function ImageEmbed({
+  embed,
+  ...rest
+}: CommonProps & {
+  embed: EmbedType<'images'>
+}) {
+  const {openLightbox} = useLightboxControls()
+  const {images} = embed.view
+
+  if (images.length > 0) {
+    const items = images.map(img => ({
+      uri: img.fullsize,
+      thumbUri: img.thumb,
+      alt: img.alt,
+      dimensions: img.aspectRatio ?? null,
+    }))
+    const _openLightbox = (
+      index: number,
+      thumbRects: (MeasuredDimensions | null)[],
+      fetchedDims: (Dimensions | null)[],
+    ) => {
+      openLightbox({
+        images: items.map((item, i) => ({
+          ...item,
+          thumbRect: thumbRects[i] ?? null,
+          thumbDimensions: fetchedDims[i] ?? null,
+          type: 'image',
+        })),
+        index,
+      })
+    }
+    const onPress = (
+      index: number,
+      refs: AnimatedRef<any>[],
+      fetchedDims: (Dimensions | null)[],
+    ) => {
+      runOnUI(() => {
+        'worklet'
+        const rects: (MeasuredDimensions | null)[] = []
+        for (const r of refs) {
+          rects.push(measure(r))
+        }
+        runOnJS(_openLightbox)(index, rects, fetchedDims)
+      })()
+    }
+    const onPressIn = (_: number) => {
+      InteractionManager.runAfterInteractions(() => {
+        Image.prefetch(items.map(i => i.uri))
+      })
+    }
+
+    if (images.length === 1) {
+      const image = images[0]
+      return (
+        <View style={[a.mt_sm, rest.style]}>
+          <AutoSizedImage
+            crop={
+              rest.viewContext === PostEmbedViewContext.ThreadHighlighted
+                ? 'none'
+                : rest.viewContext ===
+                  PostEmbedViewContext.FeedEmbedRecordWithMedia
+                ? 'square'
+                : 'constrained'
+            }
+            image={image}
+            onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
+            onPressIn={() => onPressIn(0)}
+            hideBadge={
+              rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+            }
+          />
+        </View>
+      )
+    }
+
+    return (
+      <View style={[a.mt_sm, rest.style]}>
+        <ImageLayoutGrid
+          images={images}
+          onPress={onPress}
+          onPressIn={onPressIn}
+          viewContext={rest.viewContext}
+        />
+      </View>
+    )
+  }
+}
diff --git a/src/components/Post/Embed/LazyQuoteEmbed.tsx b/src/components/Post/Embed/LazyQuoteEmbed.tsx
new file mode 100644
index 000000000..fdc1c6309
--- /dev/null
+++ b/src/components/Post/Embed/LazyQuoteEmbed.tsx
@@ -0,0 +1,37 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+
+import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
+import {useResolveLinkQuery} from '#/state/queries/resolve-link'
+import {atoms as a, useTheme} from '#/alf'
+import {QuoteEmbed} from '#/components/Post/Embed'
+
+export function LazyQuoteEmbed({uri}: {uri: string}) {
+  const t = useTheme()
+  const {data} = useResolveLinkQuery(uri)
+
+  const view = useMemo(() => {
+    if (!data || data.type !== 'record' || data.kind !== 'post') return
+    return createEmbedViewRecordFromPost(data.view)
+  }, [data])
+
+  return view ? (
+    <QuoteEmbed
+      embed={{
+        type: 'post',
+        view,
+      }}
+    />
+  ) : (
+    <View
+      style={[
+        a.w_full,
+        a.rounded_md,
+        t.atoms.bg_contrast_25,
+        {
+          height: 68,
+        },
+      ]}
+    />
+  )
+}
diff --git a/src/components/Post/Embed/ListEmbed.tsx b/src/components/Post/Embed/ListEmbed.tsx
new file mode 100644
index 000000000..dc79a7579
--- /dev/null
+++ b/src/components/Post/Embed/ListEmbed.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import {View} from 'react-native'
+import {moderateUserList} from '@atproto/api'
+
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {atoms as a, useTheme} from '#/alf'
+import * as ListCard from '#/components/ListCard'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {EmbedType} from '#/types/bsky/post'
+import {CommonProps} from './types'
+
+export function ListEmbed({
+  embed,
+}: CommonProps & {
+  embed: EmbedType<'list'>
+}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
+      <ListCard.Default view={embed.view} />
+    </View>
+  )
+}
+
+export function ModeratedListEmbed({
+  embed,
+}: CommonProps & {
+  embed: EmbedType<'list'>
+}) {
+  const moderationOpts = useModerationOpts()
+  const moderation = React.useMemo(() => {
+    return moderationOpts
+      ? moderateUserList(embed.view, moderationOpts)
+      : undefined
+  }, [embed.view, moderationOpts])
+  return (
+    <ContentHider modui={moderation?.ui('contentList')}>
+      <ListEmbed embed={embed} />
+    </ContentHider>
+  )
+}
diff --git a/src/components/Post/Embed/PostPlaceholder.tsx b/src/components/Post/Embed/PostPlaceholder.tsx
new file mode 100644
index 000000000..840234026
--- /dev/null
+++ b/src/components/Post/Embed/PostPlaceholder.tsx
@@ -0,0 +1,33 @@
+import {StyleSheet, View} from 'react-native'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {InfoCircleIcon} from '#/lib/icons'
+import {Text} from '#/view/com/util/text/Text'
+import {atoms as a, useTheme} from '#/alf'
+
+export function PostPlaceholder({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  const pal = usePalette('default')
+  return (
+    <View
+      style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
+      <InfoCircleIcon size={18} style={pal.text} />
+      <Text type="lg" style={pal.text}>
+        {children}
+      </Text>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  errorContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+    borderRadius: 8,
+    marginTop: 8,
+    paddingVertical: 14,
+    paddingHorizontal: 14,
+    borderWidth: StyleSheet.hairlineWidth,
+  },
+})
diff --git a/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx
new file mode 100644
index 000000000..a038403b2
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx
@@ -0,0 +1,114 @@
+import React, {
+  useCallback,
+  useEffect,
+  useId,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
+import {useWindowDimensions} from 'react-native'
+
+import {isNative, isWeb} from '#/platform/detection'
+
+const Context = React.createContext<{
+  activeViewId: string | null
+  setActiveView: (viewId: string) => void
+  sendViewPosition: (viewId: string, y: number) => void
+} | null>(null)
+
+export function Provider({children}: {children: React.ReactNode}) {
+  if (!isWeb) {
+    throw new Error('ActiveVideoWebContext may only be used on web.')
+  }
+
+  const [activeViewId, setActiveViewId] = useState<string | null>(null)
+  const activeViewLocationRef = useRef(Infinity)
+  const {height: windowHeight} = useWindowDimensions()
+
+  // minimising re-renders by using refs
+  const manuallySetRef = useRef(false)
+  const activeViewIdRef = useRef(activeViewId)
+  useEffect(() => {
+    activeViewIdRef.current = activeViewId
+  }, [activeViewId])
+
+  const setActiveView = useCallback(
+    (viewId: string) => {
+      setActiveViewId(viewId)
+      manuallySetRef.current = true
+      // we don't know the exact position, but it's definitely on screen
+      // so just guess that it's in the middle. Any value is fine
+      // so long as it's not offscreen
+      activeViewLocationRef.current = windowHeight / 2
+    },
+    [windowHeight],
+  )
+
+  const sendViewPosition = useCallback(
+    (viewId: string, y: number) => {
+      if (isNative) return
+
+      if (viewId === activeViewIdRef.current) {
+        activeViewLocationRef.current = y
+      } else {
+        if (
+          distanceToIdealPosition(y) <
+          distanceToIdealPosition(activeViewLocationRef.current)
+        ) {
+          // if the old view was manually set, only usurp if the old view is offscreen
+          if (
+            manuallySetRef.current &&
+            withinViewport(activeViewLocationRef.current)
+          ) {
+            return
+          }
+
+          setActiveViewId(viewId)
+          activeViewLocationRef.current = y
+          manuallySetRef.current = false
+        }
+      }
+
+      function distanceToIdealPosition(yPos: number) {
+        return Math.abs(yPos - windowHeight / 2.5)
+      }
+
+      function withinViewport(yPos: number) {
+        return yPos > 0 && yPos < windowHeight
+      }
+    },
+    [windowHeight],
+  )
+
+  const value = useMemo(
+    () => ({
+      activeViewId,
+      setActiveView,
+      sendViewPosition,
+    }),
+    [activeViewId, setActiveView, sendViewPosition],
+  )
+
+  return <Context.Provider value={value}>{children}</Context.Provider>
+}
+
+export function useActiveVideoWeb() {
+  const context = React.useContext(Context)
+  if (!context) {
+    throw new Error(
+      'useActiveVideoWeb must be used within a ActiveVideoWebProvider',
+    )
+  }
+
+  const {activeViewId, setActiveView, sendViewPosition} = context
+  const id = useId()
+
+  return {
+    active: activeViewId === id,
+    setActive: () => {
+      setActiveView(id)
+    },
+    currentActiveView: activeViewId,
+    sendPosition: (y: number) => sendViewPosition(id, y),
+  }
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx
new file mode 100644
index 000000000..95401309f
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx
@@ -0,0 +1,64 @@
+import {StyleProp, ViewStyle} from 'react-native'
+import {View} from 'react-native'
+import {msg, plural} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+/**
+ * Absolutely positioned time indicator showing how many seconds are remaining
+ * Time is in seconds
+ */
+export function TimeIndicator({
+  time,
+  style,
+}: {
+  time: number
+  style?: StyleProp<ViewStyle>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  if (isNaN(time)) {
+    return null
+  }
+
+  const minutes = Math.floor(time / 60)
+  const seconds = String(time % 60).padStart(2, '0')
+
+  return (
+    <View
+      pointerEvents="none"
+      accessibilityLabel={_(
+        msg`Time remaining: ${plural(Number(time) || 0, {
+          one: '# second',
+          other: '# seconds',
+        })}`,
+      )}
+      accessibilityHint=""
+      style={[
+        {
+          backgroundColor: 'rgba(0, 0, 0, 0.5)',
+          borderRadius: 6,
+          paddingHorizontal: 6,
+          paddingVertical: 3,
+          left: 6,
+          bottom: 6,
+          minHeight: 21,
+        },
+        a.absolute,
+        a.justify_center,
+        style,
+      ]}>
+      <Text
+        style={[
+          {color: t.palette.white, fontSize: 12, fontVariant: ['tabular-nums']},
+          a.font_bold,
+          {lineHeight: 1.25},
+        ]}>
+        {`${minutes}:${seconds}`}
+      </Text>
+    </View>
+  )
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx
new file mode 100644
index 000000000..88879d45a
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -0,0 +1,210 @@
+import React, {useRef} from 'react'
+import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyEmbedVideo} from '@atproto/api'
+import {BlueskyVideoView} from '@haileyok/bluesky-video'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {HITSLOP_30} from '#/lib/constants'
+import {useAutoplayDisabled} from '#/state/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {useIsWithinMessage} from '#/components/dms/MessageContext'
+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 {MediaInsetBorder} from '#/components/MediaInsetBorder'
+import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
+import {TimeIndicator} from './TimeIndicator'
+
+export const VideoEmbedInnerNative = React.forwardRef(
+  function VideoEmbedInnerNative(
+    {
+      embed,
+      setStatus,
+      setIsLoading,
+      setIsActive,
+    }: {
+      embed: AppBskyEmbedVideo.View
+      setStatus: (status: 'playing' | 'paused') => void
+      setIsLoading: (isLoading: boolean) => void
+      setIsActive: (isActive: boolean) => void
+    },
+    ref: React.Ref<{togglePlayback: () => void}>,
+  ) {
+    const {_} = useLingui()
+    const videoRef = useRef<BlueskyVideoView>(null)
+    const autoplayDisabled = useAutoplayDisabled()
+    const isWithinMessage = useIsWithinMessage()
+    const [muted, setMuted] = useVideoMuteState()
+
+    const [isPlaying, setIsPlaying] = React.useState(false)
+    const [timeRemaining, setTimeRemaining] = React.useState(0)
+    const [error, setError] = React.useState<string>()
+
+    React.useImperativeHandle(ref, () => ({
+      togglePlayback: () => {
+        videoRef.current?.togglePlayback()
+      },
+    }))
+
+    if (error) {
+      throw new Error(error)
+    }
+
+    return (
+      <View style={[a.flex_1, a.relative]}>
+        <BlueskyVideoView
+          url={embed.playlist}
+          autoplay={!autoplayDisabled && !isWithinMessage}
+          beginMuted={autoplayDisabled ? false : muted}
+          style={[a.rounded_sm]}
+          onActiveChange={e => {
+            setIsActive(e.nativeEvent.isActive)
+          }}
+          onLoadingChange={e => {
+            setIsLoading(e.nativeEvent.isLoading)
+          }}
+          onMutedChange={e => {
+            setMuted(e.nativeEvent.isMuted)
+          }}
+          onStatusChange={e => {
+            setStatus(e.nativeEvent.status)
+            setIsPlaying(e.nativeEvent.status === 'playing')
+          }}
+          onTimeRemainingChange={e => {
+            setTimeRemaining(e.nativeEvent.timeRemaining)
+          }}
+          onError={e => {
+            setError(e.nativeEvent.error)
+          }}
+          ref={videoRef}
+          accessibilityLabel={
+            embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
+          }
+          accessibilityHint=""
+        />
+        <VideoControls
+          enterFullscreen={() => {
+            videoRef.current?.enterFullscreen(true)
+          }}
+          toggleMuted={() => {
+            videoRef.current?.toggleMuted()
+          }}
+          togglePlayback={() => {
+            videoRef.current?.togglePlayback()
+          }}
+          isPlaying={isPlaying}
+          timeRemaining={timeRemaining}
+        />
+        <MediaInsetBorder />
+      </View>
+    )
+  },
+)
+
+function VideoControls({
+  enterFullscreen,
+  toggleMuted,
+  togglePlayback,
+  timeRemaining,
+  isPlaying,
+}: {
+  enterFullscreen: () => void
+  toggleMuted: () => void
+  togglePlayback: () => void
+  timeRemaining: number
+  isPlaying: boolean
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const [muted] = useVideoMuteState()
+
+  // show countdown when:
+  // 1. timeRemaining is a number - was seeing NaNs
+  // 2. duration is greater than 0 - means metadata has loaded
+  // 3. we're less than 5 second into the video
+  const showTime = !isNaN(timeRemaining)
+
+  return (
+    <View style={[a.absolute, a.inset_0]}>
+      <Pressable
+        onPress={enterFullscreen}
+        style={a.flex_1}
+        accessibilityLabel={_(msg`Video`)}
+        accessibilityHint={_(msg`Enters full screen`)}
+        accessibilityRole="button"
+      />
+      <ControlButton
+        onPress={togglePlayback}
+        label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
+        accessibilityHint={_(msg`Plays or pauses the video`)}
+        style={{left: 6}}>
+        {isPlaying ? (
+          <PauseIcon width={13} fill={t.palette.white} />
+        ) : (
+          <PlayIcon width={13} fill={t.palette.white} />
+        )}
+      </ControlButton>
+      {showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
+
+      <ControlButton
+        onPress={toggleMuted}
+        label={
+          muted
+            ? _(msg({message: `Unmute`, context: 'video'}))
+            : _(msg({message: `Mute`, context: 'video'}))
+        }
+        accessibilityHint={_(msg`Toggles the sound`)}
+        style={{right: 6}}>
+        {muted ? (
+          <MuteIcon width={13} fill={t.palette.white} />
+        ) : (
+          <UnmuteIcon width={13} fill={t.palette.white} />
+        )}
+      </ControlButton>
+    </View>
+  )
+}
+
+function ControlButton({
+  onPress,
+  children,
+  label,
+  accessibilityHint,
+  style,
+}: {
+  onPress: () => void
+  children: React.ReactNode
+  label: string
+  accessibilityHint: string
+  style?: StyleProp<ViewStyle>
+}) {
+  return (
+    <View
+      style={[
+        a.absolute,
+        a.rounded_full,
+        a.justify_center,
+        {
+          backgroundColor: 'rgba(0, 0, 0, 0.5)',
+          paddingHorizontal: 4,
+          paddingVertical: 4,
+          bottom: 6,
+          minHeight: 21,
+          minWidth: 21,
+        },
+        style,
+      ]}>
+      <Pressable
+        onPress={onPress}
+        style={a.flex_1}
+        accessibilityLabel={label}
+        accessibilityHint={accessibilityHint}
+        accessibilityRole="button"
+        hitSlop={HITSLOP_30}>
+        {children}
+      </Pressable>
+    </View>
+  )
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
new file mode 100644
index 000000000..2760c7faf
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
@@ -0,0 +1,3 @@
+export function VideoEmbedInnerNative() {
+  throw new Error('VideoEmbedInnerNative may not be used on web.')
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
new file mode 100644
index 000000000..8664aae14
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
@@ -0,0 +1,3 @@
+export function VideoEmbedInnerWeb() {
+  throw new Error('VideoEmbedInnerWeb may not be used on native.')
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
new file mode 100644
index 000000000..ce3a7b2c9
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -0,0 +1,307 @@
+import {useEffect, useId, useRef, useState} from 'react'
+import {View} from 'react-native'
+import {type AppBskyEmbedVideo} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import type * as HlsTypes from 'hls.js'
+
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {atoms as a} from '#/alf'
+import {MediaInsetBorder} from '#/components/MediaInsetBorder'
+import * as BandwidthEstimate from './bandwidth-estimate'
+import {Controls} from './web-controls/VideoControls'
+
+export function VideoEmbedInnerWeb({
+  embed,
+  active,
+  setActive,
+  onScreen,
+  lastKnownTime,
+}: {
+  embed: AppBskyEmbedVideo.View
+  active: boolean
+  setActive: () => void
+  onScreen: boolean
+  lastKnownTime: React.MutableRefObject<number | undefined>
+}) {
+  const containerRef = useRef<HTMLDivElement>(null)
+  const videoRef = useRef<HTMLVideoElement>(null)
+  const [focused, setFocused] = useState(false)
+  const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
+  const [hlsLoading, setHlsLoading] = useState(false)
+  const figId = useId()
+  const {_} = useLingui()
+
+  // send error up to error boundary
+  const [error, setError] = useState<Error | null>(null)
+  if (error) {
+    throw error
+  }
+
+  const hlsRef = useHLS({
+    playlist: embed.playlist,
+    setHasSubtitleTrack,
+    setError,
+    videoRef,
+    setHlsLoading,
+  })
+
+  useEffect(() => {
+    if (lastKnownTime.current && videoRef.current) {
+      videoRef.current.currentTime = lastKnownTime.current
+    }
+  }, [lastKnownTime])
+
+  return (
+    <View
+      style={[a.flex_1, a.rounded_md, a.overflow_hidden]}
+      accessibilityLabel={_(msg`Embedded video player`)}
+      accessibilityHint="">
+      <div ref={containerRef} style={{height: '100%', width: '100%'}}>
+        <figure style={{margin: 0, position: 'absolute', inset: 0}}>
+          <video
+            ref={videoRef}
+            poster={embed.thumbnail}
+            style={{width: '100%', height: '100%', objectFit: 'contain'}}
+            playsInline
+            preload="none"
+            muted={!focused}
+            aria-labelledby={embed.alt ? figId : undefined}
+            onTimeUpdate={e => {
+              lastKnownTime.current = e.currentTarget.currentTime
+            }}
+          />
+          {embed.alt && (
+            <figcaption
+              id={figId}
+              style={{
+                position: 'absolute',
+                width: 1,
+                height: 1,
+                padding: 0,
+                margin: -1,
+                overflow: 'hidden',
+                clip: 'rect(0, 0, 0, 0)',
+                whiteSpace: 'nowrap',
+                borderWidth: 0,
+              }}>
+              {embed.alt}
+            </figcaption>
+          )}
+        </figure>
+        <Controls
+          videoRef={videoRef}
+          hlsRef={hlsRef}
+          active={active}
+          setActive={setActive}
+          focused={focused}
+          setFocused={setFocused}
+          hlsLoading={hlsLoading}
+          onScreen={onScreen}
+          fullscreenRef={containerRef}
+          hasSubtitleTrack={hasSubtitleTrack}
+        />
+      </div>
+      <MediaInsetBorder />
+    </View>
+  )
+}
+
+export class HLSUnsupportedError extends Error {
+  constructor() {
+    super('HLS is not supported')
+  }
+}
+
+export class VideoNotFoundError extends Error {
+  constructor() {
+    super('Video not found')
+  }
+}
+
+type CachedPromise<T> = Promise<T> & {value: undefined | T}
+const promiseForHls = import(
+  // @ts-ignore
+  'hls.js/dist/hls.min'
+).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>
+promiseForHls.value = undefined
+promiseForHls.then(Hls => {
+  promiseForHls.value = Hls
+})
+
+function useHLS({
+  playlist,
+  setHasSubtitleTrack,
+  setError,
+  videoRef,
+  setHlsLoading,
+}: {
+  playlist: string
+  setHasSubtitleTrack: (v: boolean) => void
+  setError: (v: Error | null) => void
+  videoRef: React.RefObject<HTMLVideoElement>
+  setHlsLoading: (v: boolean) => void
+}) {
+  const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>(
+    () => promiseForHls.value,
+  )
+  useEffect(() => {
+    if (!Hls) {
+      setHlsLoading(true)
+      promiseForHls.then(loadedHls => {
+        setHls(() => loadedHls)
+        setHlsLoading(false)
+      })
+    }
+  }, [Hls, setHlsLoading])
+
+  const hlsRef = useRef<HlsTypes.default | undefined>(undefined)
+  const [lowQualityFragments, setLowQualityFragments] = useState<
+    HlsTypes.Fragment[]
+  >([])
+
+  // purge low quality segments from buffer on next frag change
+  const handleFragChange = useNonReactiveCallback(
+    (
+      _event: HlsTypes.Events.FRAG_CHANGED,
+      {frag}: HlsTypes.FragChangedData,
+    ) => {
+      if (!Hls) return
+      if (!hlsRef.current) return
+      const hls = hlsRef.current
+
+      // if the current quality level goes above 0, flush the low quality segments
+      if (hls.nextAutoLevel > 0) {
+        const flushed: HlsTypes.Fragment[] = []
+
+        for (const lowQualFrag of lowQualityFragments) {
+          // avoid if close to the current fragment
+          if (Math.abs(frag.start - lowQualFrag.start) < 0.1) {
+            continue
+          }
+
+          hls.trigger(Hls.Events.BUFFER_FLUSHING, {
+            startOffset: lowQualFrag.start,
+            endOffset: lowQualFrag.end,
+            type: 'video',
+          })
+
+          flushed.push(lowQualFrag)
+        }
+
+        setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f)))
+      }
+    },
+  )
+
+  const flushOnLoop = useNonReactiveCallback(() => {
+    if (!Hls) return
+    if (!hlsRef.current) return
+    const hls = hlsRef.current
+    // the above callback will catch most stale frags, but there's a corner case -
+    // if there's only one segment in the video, it won't get flushed because it avoids
+    // flushing the currently active segment. Therefore, we have to catch it when we loop
+    if (
+      hls.nextAutoLevel > 0 &&
+      lowQualityFragments.length === 1 &&
+      lowQualityFragments[0].start === 0
+    ) {
+      const lowQualFrag = lowQualityFragments[0]
+
+      hls.trigger(Hls.Events.BUFFER_FLUSHING, {
+        startOffset: lowQualFrag.start,
+        endOffset: lowQualFrag.end,
+        type: 'video',
+      })
+      setLowQualityFragments([])
+    }
+  })
+
+  useEffect(() => {
+    if (!videoRef.current) return
+    if (!Hls) return
+    if (!Hls.isSupported()) {
+      throw new HLSUnsupportedError()
+    }
+
+    const hls = new Hls({
+      maxMaxBufferLength: 10, // only load 10s ahead
+      // note: the amount buffered is affected by both maxBufferLength and maxBufferSize
+      // it will buffer until it is greater than *both* of those values
+      // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
+    })
+    hlsRef.current = hls
+
+    const latestEstimate = BandwidthEstimate.get()
+    if (latestEstimate !== undefined) {
+      hls.bandwidthEstimate = latestEstimate
+    }
+
+    hls.attachMedia(videoRef.current)
+    hls.loadSource(playlist)
+
+    // manually loop, so if we've flushed the first buffer it doesn't get confused
+    const abortController = new AbortController()
+    const {signal} = abortController
+    const videoNode = videoRef.current
+    videoNode.addEventListener(
+      'ended',
+      () => {
+        flushOnLoop()
+        videoNode.currentTime = 0
+        videoNode.play()
+      },
+      {signal},
+    )
+
+    hls.on(Hls.Events.FRAG_LOADED, () => {
+      BandwidthEstimate.set(hls.bandwidthEstimate)
+    })
+
+    hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
+      if (data.subtitleTracks.length > 0) {
+        setHasSubtitleTrack(true)
+      }
+    })
+
+    hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => {
+      if (frag.level === 0) {
+        setLowQualityFragments(prev => [...prev, frag])
+      }
+    })
+
+    hls.on(Hls.Events.ERROR, (_event, data) => {
+      if (data.fatal) {
+        if (
+          data.details === 'manifestLoadError' &&
+          data.response?.code === 404
+        ) {
+          setError(new VideoNotFoundError())
+        } else {
+          setError(data.error)
+        }
+      } else {
+        console.error(data.error)
+      }
+    })
+
+    hls.on(Hls.Events.FRAG_CHANGED, handleFragChange)
+
+    return () => {
+      hlsRef.current = undefined
+      hls.detachMedia()
+      hls.destroy()
+      abortController.abort()
+    }
+  }, [
+    playlist,
+    setError,
+    setHasSubtitleTrack,
+    videoRef,
+    handleFragChange,
+    flushOnLoop,
+    Hls,
+  ])
+
+  return hlsRef
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx
new file mode 100644
index 000000000..1b46163cc
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text as TypoText} from '#/components/Typography'
+
+export function Container({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.flex_1,
+        t.atoms.bg_contrast_25,
+        a.justify_center,
+        a.align_center,
+        a.px_lg,
+        a.border,
+        t.atoms.border_contrast_low,
+        a.rounded_sm,
+        a.gap_lg,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+export function Text({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  return (
+    <TypoText
+      style={[
+        a.text_center,
+        t.atoms.text_contrast_high,
+        a.text_md,
+        a.leading_snug,
+        {maxWidth: 300},
+      ]}>
+      {children}
+    </TypoText>
+  )
+}
+
+export function RetryButton({onPress}: {onPress: () => void}) {
+  const {_} = useLingui()
+
+  return (
+    <Button
+      onPress={onPress}
+      size="small"
+      color="secondary_inverted"
+      variant="solid"
+      label={_(msg`Retry`)}>
+      <ButtonText>
+        <Trans>Retry</Trans>
+      </ButtonText>
+    </Button>
+  )
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts
new file mode 100644
index 000000000..122e10aef
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts
@@ -0,0 +1,11 @@
+let latestBandwidthEstimate: number | undefined
+
+export function get() {
+  return latestBandwidthEstimate
+}
+
+export function set(estimate: number) {
+  if (!isNaN(estimate)) {
+    latestBandwidthEstimate = estimate
+  }
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx
new file mode 100644
index 000000000..1b69a3e25
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import {SvgProps} from 'react-native-svg'
+
+import {PressableWithHover} from '#/view/com/util/PressableWithHover'
+import {atoms as a, useTheme, web} from '#/alf'
+
+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 (
+    <PressableWithHover
+      accessibilityRole="button"
+      accessibilityLabel={active ? activeLabel : inactiveLabel}
+      accessibilityHint=""
+      onPress={onPress}
+      style={[
+        a.p_xs,
+        a.rounded_full,
+        web({transition: 'background-color 0.1s'}),
+      ]}
+      hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.2)'}}>
+      {active ? (
+        <ActiveIcon fill={t.palette.white} width={20} aria-hidden />
+      ) : (
+        <InactiveIcon fill={t.palette.white} width={20} aria-hidden />
+      )}
+    </PressableWithHover>
+  )
+}
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>
+  )
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx
new file mode 100644
index 000000000..e2e24ed36
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx
@@ -0,0 +1,3 @@
+export function Controls() {
+  throw new Error('VideoWebControls may not be used on native.')
+}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
new file mode 100644
index 000000000..6d14deafc
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
@@ -0,0 +1,427 @@
+import {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,
+  hlsLoading,
+  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>
+  hlsLoading: boolean
+  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 showSpinner = hlsLoading || buffering
+  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) {
+      // allow 30s of buffering
+      hlsRef.current.config.maxMaxBufferLength = 30
+    } else {
+      // back to what we initially set
+      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}
+        accessibilityLabel={_(
+          !focused
+            ? msg`Unmute video`
+            : playing
+            ? msg`Pause video`
+            : msg`Play video`,
+        )}
+        accessibilityHint=""
+        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.pb_sm,
+            a.gap_sm,
+            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={[
+              a.px_xs,
+              {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`Enter fullscreen`)}
+              activeIcon={ArrowsInIcon}
+              inactiveIcon={ArrowsOutIcon}
+              onPress={onPressFullscreen}
+            />
+          )}
+        </View>
+      </View>
+      {(showSpinner || error) && (
+        <View
+          pointerEvents="none"
+          style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+          {showSpinner && <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/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx
new file mode 100644
index 000000000..e0b688075
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx
@@ -0,0 +1,110 @@
+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 '#/components/Post/Embed/VideoEmbed/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}
+              aria-label={_(msg`Volume`)}
+              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/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx
new file mode 100644
index 000000000..320f61a5f
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx
@@ -0,0 +1,240 @@
+import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
+
+import {isSafari} from '#/lib/browser'
+import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
+
+export function useVideoElement(ref: 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)
+      // HACK: Safari randomly fires `stalled` events when changing between segments
+      // let's just clear the buffering state if the video is still progressing -sfn
+      if (isSafari) {
+        if (bufferingTimeout) clearTimeout(bufferingTimeout)
+        setBuffering(false)
+      }
+    }
+
+    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 = async () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      setBuffering(false)
+      setCanPlay(true)
+
+      if (!ref.current) return
+      if (playWhenReadyRef.current) {
+        try {
+          await ref.current.play()
+        } catch (e: any) {
+          if (
+            !e.message?.includes(`The request is not allowed by the user agent`)
+          ) {
+            throw e
+          }
+        }
+        playWhenReadyRef.current = false
+      }
+    }
+
+    const handleCanPlayThrough = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      setBuffering(false)
+    }
+
+    const handleWaiting = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      bufferingTimeout = setTimeout(() => {
+        setBuffering(true)
+      }, 500) // 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)
+      }, 500) // 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, 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/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx
new file mode 100644
index 000000000..6343081da
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx
@@ -0,0 +1,47 @@
+import React from 'react'
+
+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, volume, setVolume],
+  )
+
+  return <Context.Provider value={value}>{children}</Context.Provider>
+}
+
+export function useVideoVolumeState() {
+  const context = React.useContext(Context)
+  if (!context) {
+    throw new Error(
+      'useVideoVolumeState must be used within a VideoVolumeProvider',
+    )
+  }
+  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
+}
diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx
new file mode 100644
index 000000000..fe29ecad6
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/index.tsx
@@ -0,0 +1,167 @@
+import React, {useCallback, useState} from 'react'
+import {ActivityIndicator, View} from 'react-native'
+import {ImageBackground} from 'expo-image'
+import {AppBskyEmbedVideo} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
+import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {useThrottledValue} from '#/components/hooks/useThrottledValue'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
+import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
+import * as VideoFallback from './VideoEmbedInner/VideoFallback'
+
+interface Props {
+  embed: AppBskyEmbedVideo.View
+  crop?: 'none' | 'square' | 'constrained'
+}
+
+export function VideoEmbed({embed, crop}: Props) {
+  const t = useTheme()
+  const [key, setKey] = useState(0)
+
+  const renderError = useCallback(
+    (error: unknown) => (
+      <VideoError error={error} retry={() => setKey(key + 1)} />
+    ),
+    [key],
+  )
+
+  let aspectRatio: number | undefined
+  const dims = embed.aspectRatio
+  if (dims) {
+    aspectRatio = dims.width / dims.height
+    if (Number.isNaN(aspectRatio)) {
+      aspectRatio = undefined
+    }
+  }
+
+  let constrained: number | undefined
+  let max: number | undefined
+  if (aspectRatio !== undefined) {
+    const ratio = 1 / 2 // max of 1:2 ratio in feeds
+    constrained = Math.max(aspectRatio, ratio)
+    max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
+  }
+  const cropDisabled = crop === 'none'
+
+  const contents = (
+    <ErrorBoundary renderError={renderError} key={key}>
+      <InnerWrapper embed={embed} />
+    </ErrorBoundary>
+  )
+
+  return (
+    <View style={[a.pt_xs]}>
+      {cropDisabled ? (
+        <View
+          style={[
+            a.w_full,
+            a.overflow_hidden,
+            {aspectRatio: max ?? 1},
+            a.rounded_md,
+            a.overflow_hidden,
+            t.atoms.bg_contrast_25,
+          ]}>
+          {contents}
+        </View>
+      ) : (
+        <ConstrainedImage
+          fullBleed={crop === 'square'}
+          aspectRatio={constrained || 1}>
+          {contents}
+        </ConstrainedImage>
+      )}
+    </View>
+  )
+}
+
+function InnerWrapper({embed}: Props) {
+  const {_} = useLingui()
+  const ref = React.useRef<{togglePlayback: () => void}>(null)
+
+  const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>(
+    'pending',
+  )
+  const [isLoading, setIsLoading] = React.useState(false)
+  const [isActive, setIsActive] = React.useState(false)
+  const showSpinner = useThrottledValue(isActive && isLoading, 100)
+
+  const showOverlay =
+    !isActive ||
+    isLoading ||
+    (status === 'paused' && !isActive) ||
+    status === 'pending'
+
+  React.useEffect(() => {
+    if (!isActive && status !== 'pending') {
+      setStatus('pending')
+    }
+  }, [isActive, status])
+
+  return (
+    <>
+      <VideoEmbedInnerNative
+        embed={embed}
+        setStatus={setStatus}
+        setIsLoading={setIsLoading}
+        setIsActive={setIsActive}
+        ref={ref}
+      />
+      <ImageBackground
+        source={{uri: embed.thumbnail}}
+        accessibilityIgnoresInvertColors
+        style={[
+          a.absolute,
+          a.inset_0,
+          {
+            backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here,
+            // the play button won't show up on the first render on android 🥴😮‍💨
+            display: showOverlay ? 'flex' : 'none',
+          },
+        ]}
+        cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
+      >
+        {showOverlay && (
+          <Button
+            style={[a.flex_1, a.align_center, a.justify_center]}
+            onPress={() => {
+              ref.current?.togglePlayback()
+            }}
+            label={_(msg`Play video`)}
+            color="secondary">
+            {showSpinner ? (
+              <View
+                style={[
+                  a.rounded_full,
+                  a.p_xs,
+                  a.align_center,
+                  a.justify_center,
+                ]}>
+                <ActivityIndicator size="large" color="white" />
+              </View>
+            ) : (
+              <PlayButtonIcon />
+            )}
+          </Button>
+        )}
+      </ImageBackground>
+    </>
+  )
+}
+
+function VideoError({retry}: {error: unknown; retry: () => void}) {
+  return (
+    <VideoFallback.Container>
+      <VideoFallback.Text>
+        <Trans>
+          An error occurred while loading the video. Please try again later.
+        </Trans>
+      </VideoFallback.Text>
+      <VideoFallback.RetryButton onPress={retry} />
+    </VideoFallback.Container>
+  )
+}
diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx
new file mode 100644
index 000000000..53adc3b6a
--- /dev/null
+++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx
@@ -0,0 +1,207 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react'
+import {View} from 'react-native'
+import {AppBskyEmbedVideo} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isFirefox} from '#/lib/browser'
+import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
+import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
+import {atoms as a} from '#/alf'
+import {useIsWithinMessage} from '#/components/dms/MessageContext'
+import {useFullscreen} from '#/components/hooks/useFullscreen'
+import {
+  HLSUnsupportedError,
+  VideoEmbedInnerWeb,
+  VideoNotFoundError,
+} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
+import {useActiveVideoWeb} from './ActiveVideoWebContext'
+import * as VideoFallback from './VideoEmbedInner/VideoFallback'
+
+export function VideoEmbed({
+  embed,
+  crop,
+}: {
+  embed: AppBskyEmbedVideo.View
+  crop?: 'none' | 'square' | 'constrained'
+}) {
+  const ref = useRef<HTMLDivElement>(null)
+  const {active, setActive, sendPosition, currentActiveView} =
+    useActiveVideoWeb()
+  const [onScreen, setOnScreen] = useState(false)
+  const [isFullscreen] = useFullscreen()
+  const lastKnownTime = useRef<number | undefined>()
+
+  useEffect(() => {
+    if (!ref.current) return
+    if (isFullscreen && !isFirefox) return
+    const observer = new IntersectionObserver(
+      entries => {
+        const entry = entries[0]
+        if (!entry) return
+        setOnScreen(entry.isIntersecting)
+        sendPosition(
+          entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
+        )
+      },
+      {threshold: 0.5},
+    )
+    observer.observe(ref.current)
+    return () => observer.disconnect()
+  }, [sendPosition, isFullscreen])
+
+  const [key, setKey] = useState(0)
+  const renderError = useCallback(
+    (error: unknown) => (
+      <VideoError error={error} retry={() => setKey(key + 1)} />
+    ),
+    [key],
+  )
+
+  let aspectRatio: number | undefined
+  const dims = embed.aspectRatio
+  if (dims) {
+    aspectRatio = dims.width / dims.height
+    if (Number.isNaN(aspectRatio)) {
+      aspectRatio = undefined
+    }
+  }
+
+  let constrained: number | undefined
+  let max: number | undefined
+  if (aspectRatio !== undefined) {
+    const ratio = 1 / 2 // max of 1:2 ratio in feeds
+    constrained = Math.max(aspectRatio, ratio)
+    max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
+  }
+  const cropDisabled = crop === 'none'
+
+  const contents = (
+    <div
+      ref={ref}
+      style={{display: 'flex', flex: 1, cursor: 'default'}}
+      onClick={evt => evt.stopPropagation()}>
+      <ErrorBoundary renderError={renderError} key={key}>
+        <ViewportObserver
+          sendPosition={sendPosition}
+          isAnyViewActive={currentActiveView !== null}>
+          <VideoEmbedInnerWeb
+            embed={embed}
+            active={active}
+            setActive={setActive}
+            onScreen={onScreen}
+            lastKnownTime={lastKnownTime}
+          />
+        </ViewportObserver>
+      </ErrorBoundary>
+    </div>
+  )
+
+  return (
+    <View style={[a.pt_xs]}>
+      {cropDisabled ? (
+        <View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}>
+          {contents}
+        </View>
+      ) : (
+        <ConstrainedImage
+          fullBleed={crop === 'square'}
+          aspectRatio={constrained || 1}>
+          {contents}
+        </ConstrainedImage>
+      )}
+    </View>
+  )
+}
+
+/**
+ * Renders a 100vh tall div and watches it with an IntersectionObserver to
+ * send the position of the div when it's near the screen.
+ */
+function ViewportObserver({
+  children,
+  sendPosition,
+  isAnyViewActive,
+}: {
+  children: React.ReactNode
+  sendPosition: (position: number) => void
+  isAnyViewActive: boolean
+}) {
+  const ref = useRef<HTMLDivElement>(null)
+  const [nearScreen, setNearScreen] = useState(false)
+  const [isFullscreen] = useFullscreen()
+  const isWithinMessage = useIsWithinMessage()
+
+  // Send position when scrolling. This is done with an IntersectionObserver
+  // observing a div of 100vh height
+  useEffect(() => {
+    if (!ref.current) return
+    if (isFullscreen && !isFirefox) return
+    const observer = new IntersectionObserver(
+      entries => {
+        const entry = entries[0]
+        if (!entry) return
+        const position =
+          entry.boundingClientRect.y + entry.boundingClientRect.height / 2
+        sendPosition(position)
+        setNearScreen(entry.isIntersecting)
+      },
+      {threshold: Array.from({length: 101}, (_, i) => i / 100)},
+    )
+    observer.observe(ref.current)
+    return () => observer.disconnect()
+  }, [sendPosition, isFullscreen])
+
+  // In case scrolling hasn't started yet, send up the position
+  useEffect(() => {
+    if (ref.current && !isAnyViewActive) {
+      const rect = ref.current.getBoundingClientRect()
+      const position = rect.y + rect.height / 2
+      sendPosition(position)
+    }
+  }, [isAnyViewActive, sendPosition])
+
+  return (
+    <View style={[a.flex_1, a.flex_row]}>
+      {nearScreen && children}
+      <div
+        ref={ref}
+        style={{
+          // Don't escape bounds when in a message
+          ...(isWithinMessage
+            ? {top: 0, height: '100%'}
+            : {top: 'calc(50% - 50vh)', height: '100vh'}),
+          position: 'absolute',
+          left: '50%',
+          width: 1,
+          pointerEvents: 'none',
+        }}
+      />
+    </View>
+  )
+}
+
+function VideoError({error, retry}: {error: unknown; retry: () => void}) {
+  const {_} = useLingui()
+
+  let showRetryButton = true
+  let text = null
+
+  if (error instanceof VideoNotFoundError) {
+    text = _(msg`Video not found.`)
+  } else if (error instanceof HLSUnsupportedError) {
+    showRetryButton = false
+    text = _(
+      msg`Your browser does not support the video format. Please try a different browser.`,
+    )
+  } else {
+    text = _(msg`An error occurred while loading the video. Please try again.`)
+  }
+
+  return (
+    <VideoFallback.Container>
+      <VideoFallback.Text>{text}</VideoFallback.Text>
+      {showRetryButton && <VideoFallback.RetryButton onPress={retry} />}
+    </VideoFallback.Container>
+  )
+}
diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx
new file mode 100644
index 000000000..ace85dc98
--- /dev/null
+++ b/src/components/Post/Embed/index.tsx
@@ -0,0 +1,332 @@
+import React from 'react'
+import {View} from 'react-native'
+import {
+  type $Typed,
+  type AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AtUri,
+  moderatePost,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {makeProfileLink} from '#/lib/routes/links'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {unstableCacheProfileView} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {Link} from '#/view/com/util/Link'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {atoms as a, useTheme} from '#/alf'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {RichText} from '#/components/RichText'
+import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
+import {SubtleWebHover} from '#/components/SubtleWebHover'
+import * as bsky from '#/types/bsky'
+import {
+  type Embed as TEmbed,
+  type EmbedType,
+  parseEmbed,
+} from '#/types/bsky/post'
+import {ExternalEmbed} from './ExternalEmbed'
+import {ModeratedFeedEmbed} from './FeedEmbed'
+import {ImageEmbed} from './ImageEmbed'
+import {ModeratedListEmbed} from './ListEmbed'
+import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
+import {
+  type CommonProps,
+  type EmbedProps,
+  PostEmbedViewContext,
+  QuoteEmbedViewContext,
+} from './types'
+import {VideoEmbed} from './VideoEmbed'
+
+export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
+
+export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
+  const embed = parseEmbed(rawEmbed)
+
+  switch (embed.type) {
+    case 'images':
+    case 'link':
+    case 'video': {
+      return <MediaEmbed embed={embed} {...rest} />
+    }
+    case 'feed':
+    case 'list':
+    case 'starter_pack':
+    case 'labeler':
+    case 'post':
+    case 'post_not_found':
+    case 'post_blocked':
+    case 'post_detached': {
+      return <RecordEmbed embed={embed} {...rest} />
+    }
+    case 'post_with_media': {
+      return (
+        <View style={rest.style}>
+          <MediaEmbed embed={embed.media} {...rest} />
+          <RecordEmbed embed={embed.view} {...rest} />
+        </View>
+      )
+    }
+    default: {
+      return null
+    }
+  }
+}
+
+function MediaEmbed({
+  embed,
+  ...rest
+}: CommonProps & {
+  embed: TEmbed
+}) {
+  switch (embed.type) {
+    case 'images': {
+      return (
+        <ContentHider modui={rest.moderation?.ui('contentMedia')}>
+          <ImageEmbed embed={embed} {...rest} />
+        </ContentHider>
+      )
+    }
+    case 'link': {
+      return (
+        <ContentHider modui={rest.moderation?.ui('contentMedia')}>
+          <ExternalEmbed
+            link={embed.view.external}
+            onOpen={rest.onOpen}
+            style={[a.mt_sm, rest.style]}
+          />
+        </ContentHider>
+      )
+    }
+    case 'video': {
+      return (
+        <ContentHider modui={rest.moderation?.ui('contentMedia')}>
+          <VideoEmbed embed={embed.view} />
+        </ContentHider>
+      )
+    }
+    default: {
+      return null
+    }
+  }
+}
+
+function RecordEmbed({
+  embed,
+  ...rest
+}: CommonProps & {
+  embed: TEmbed
+}) {
+  switch (embed.type) {
+    case 'feed': {
+      return (
+        <View style={a.mt_sm}>
+          <ModeratedFeedEmbed embed={embed} {...rest} />
+        </View>
+      )
+    }
+    case 'list': {
+      return (
+        <View style={a.mt_sm}>
+          <ModeratedListEmbed embed={embed} />
+        </View>
+      )
+    }
+    case 'starter_pack': {
+      return (
+        <View style={a.mt_sm}>
+          <StarterPackCard starterPack={embed.view} />
+        </View>
+      )
+    }
+    case 'labeler': {
+      // not implemented
+      return null
+    }
+    case 'post': {
+      if (rest.isWithinQuote && !rest.allowNestedQuotes) {
+        return null
+      }
+
+      return (
+        <QuoteEmbed
+          {...rest}
+          embed={embed}
+          viewContext={
+            rest.viewContext === PostEmbedViewContext.Feed
+              ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
+              : undefined
+          }
+          isWithinQuote={rest.isWithinQuote}
+          allowNestedQuotes={rest.allowNestedQuotes}
+        />
+      )
+    }
+    case 'post_not_found': {
+      return (
+        <PostPlaceholderText>
+          <Trans>Deleted</Trans>
+        </PostPlaceholderText>
+      )
+    }
+    case 'post_blocked': {
+      return (
+        <PostPlaceholderText>
+          <Trans>Blocked</Trans>
+        </PostPlaceholderText>
+      )
+    }
+    case 'post_detached': {
+      return <PostDetachedEmbed embed={embed} />
+    }
+    default: {
+      return null
+    }
+  }
+}
+
+export function PostDetachedEmbed({
+  embed,
+}: {
+  embed: EmbedType<'post_detached'>
+}) {
+  const {currentAccount} = useSession()
+  const isViewerOwner = currentAccount?.did
+    ? embed.view.uri.includes(currentAccount.did)
+    : false
+
+  return (
+    <PostPlaceholderText>
+      {isViewerOwner ? (
+        <Trans>Removed by you</Trans>
+      ) : (
+        <Trans>Removed by author</Trans>
+      )}
+    </PostPlaceholderText>
+  )
+}
+
+/*
+ * Nests parent `Embed` component and therefore must live in this file to avoid
+ * circular imports.
+ */
+export function QuoteEmbed({
+  embed,
+  onOpen,
+  style,
+  isWithinQuote: parentIsWithinQuote,
+  allowNestedQuotes: parentAllowNestedQuotes,
+}: Omit<CommonProps, 'viewContext'> & {
+  embed: EmbedType<'post'>
+  viewContext?: QuoteEmbedViewContext
+}) {
+  const moderationOpts = useModerationOpts()
+  const quote = React.useMemo<$Typed<AppBskyFeedDefs.PostView>>(
+    () => ({
+      ...embed.view,
+      $type: 'app.bsky.feed.defs#postView',
+      record: embed.view.value,
+      embed: embed.view.embeds?.[0],
+    }),
+    [embed],
+  )
+  const moderation = React.useMemo(() => {
+    return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
+  }, [quote, moderationOpts])
+
+  const t = useTheme()
+  const queryClient = useQueryClient()
+  const pal = usePalette('default')
+  const itemUrip = new AtUri(quote.uri)
+  const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
+  const itemTitle = `Post by ${quote.author.handle}`
+
+  const richText = React.useMemo(() => {
+    if (
+      !bsky.dangerousIsType<AppBskyFeedPost.Record>(
+        quote.record,
+        AppBskyFeedPost.isRecord,
+      )
+    )
+      return undefined
+    const {text, facets} = quote.record
+    return text.trim()
+      ? new RichTextAPI({text: text, facets: facets})
+      : undefined
+  }, [quote.record])
+
+  const onBeforePress = React.useCallback(() => {
+    unstableCacheProfileView(queryClient, quote.author)
+    onOpen?.()
+  }, [queryClient, quote.author, onOpen])
+
+  const [hover, setHover] = React.useState(false)
+  return (
+    <View
+      onPointerEnter={() => {
+        setHover(true)
+      }}
+      onPointerLeave={() => {
+        setHover(false)
+      }}>
+      <ContentHider
+        modui={moderation?.ui('contentList')}
+        style={[
+          a.rounded_md,
+          a.p_md,
+          a.mt_sm,
+          a.border,
+          t.atoms.border_contrast_low,
+          style,
+        ]}
+        childContainerStyle={[a.pt_sm]}>
+        <SubtleWebHover hover={hover} />
+        <Link
+          hoverStyle={{borderColor: pal.colors.borderLinkHover}}
+          href={itemHref}
+          title={itemTitle}
+          onBeforePress={onBeforePress}>
+          <View pointerEvents="none">
+            <PostMeta
+              author={quote.author}
+              moderation={moderation}
+              showAvatar
+              postHref={itemHref}
+              timestamp={quote.indexedAt}
+            />
+          </View>
+          {moderation ? (
+            <PostAlerts
+              modui={moderation.ui('contentView')}
+              style={[a.py_xs]}
+            />
+          ) : null}
+          {richText ? (
+            <RichText
+              value={richText}
+              style={a.text_md}
+              numberOfLines={20}
+              disableLinks
+            />
+          ) : null}
+          {quote.embed && (
+            <Embed
+              embed={quote.embed}
+              moderation={moderation}
+              isWithinQuote={parentIsWithinQuote ?? true}
+              // already within quote? override nested
+              allowNestedQuotes={
+                parentIsWithinQuote ? false : parentAllowNestedQuotes
+              }
+            />
+          )}
+        </Link>
+      </ContentHider>
+    </View>
+  )
+}
diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts
new file mode 100644
index 000000000..b719d00b4
--- /dev/null
+++ b/src/components/Post/Embed/types.ts
@@ -0,0 +1,25 @@
+import {type StyleProp, type ViewStyle} from 'react-native'
+import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api'
+
+export enum PostEmbedViewContext {
+  ThreadHighlighted = 'ThreadHighlighted',
+  Feed = 'Feed',
+  FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
+}
+
+export enum QuoteEmbedViewContext {
+  FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
+}
+
+export type CommonProps = {
+  moderation?: ModerationDecision
+  onOpen?: () => void
+  style?: StyleProp<ViewStyle>
+  viewContext?: PostEmbedViewContext
+  isWithinQuote?: boolean
+  allowNestedQuotes?: boolean
+}
+
+export type EmbedProps = CommonProps & {
+  embed?: AppBskyFeedDefs.PostView['embed']
+}