about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-09-13 12:44:42 -0700
committerGitHub <noreply@github.com>2024-09-13 12:44:42 -0700
commit26508cfe6a89df4ae1ab1256753faa860597bbc8 (patch)
tree8f0bf4e8f65863ddbe8d1ede7df3fd342e6ab69b /src
parent78a531f5ffe9287b5384ec1649dfbc45435ced28 (diff)
downloadvoidsky-26508cfe6a89df4ae1ab1256753faa860597bbc8.tar.zst
[Video] Remove `expo-video`, use `bluesky-video` (#5282)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx79
-rw-r--r--src/components/video/PlayButtonIcon.tsx2
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx24
-rw-r--r--src/view/com/util/List.tsx8
-rw-r--r--src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx65
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.tsx142
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx15
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx305
8 files changed, 262 insertions, 378 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 83f133e99..04fea126c 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -52,7 +52,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
 import {TestCtrls} from '#/view/com/testing/TestCtrls'
-import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
 import * as Toast from '#/view/com/util/Toast'
 import {Shell} from '#/view/shell'
 import {ThemeProvider as Alf} from '#/alf'
@@ -63,7 +62,6 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo
 import {Provider as PortalProvider} from '#/components/Portal'
 import {Splash} from '#/Splash'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
-import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
 
 SplashScreen.preventAutoHideAsync()
 
@@ -110,45 +108,42 @@ function InnerApp() {
     <Alf theme={theme}>
       <ThemeProvider theme={theme}>
         <Splash isReady={isReady && hasCheckedReferrer}>
-          <ActiveVideoProvider>
-            <RootSiblingParent>
-              <React.Fragment
-                // Resets the entire tree below when it changes:
-                key={currentAccount?.did}>
-                <QueryProvider currentDid={currentAccount?.did}>
-                  <StatsigProvider>
-                    <MessagesProvider>
-                      {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-                      <LabelDefsProvider>
-                        <ModerationOptsProvider>
-                          <LoggedOutViewProvider>
-                            <SelectedFeedProvider>
-                              <HiddenRepliesProvider>
-                                <UnreadNotifsProvider>
-                                  <BackgroundNotificationPreferencesProvider>
-                                    <MutedThreadsProvider>
-                                      <ProgressGuideProvider>
-                                        <GestureHandlerRootView
-                                          style={s.h100pct}>
-                                          <TestCtrls />
-                                          <Shell />
-                                          <NuxDialogs />
-                                        </GestureHandlerRootView>
-                                      </ProgressGuideProvider>
-                                    </MutedThreadsProvider>
-                                  </BackgroundNotificationPreferencesProvider>
-                                </UnreadNotifsProvider>
-                              </HiddenRepliesProvider>
-                            </SelectedFeedProvider>
-                          </LoggedOutViewProvider>
-                        </ModerationOptsProvider>
-                      </LabelDefsProvider>
-                    </MessagesProvider>
-                  </StatsigProvider>
-                </QueryProvider>
-              </React.Fragment>
-            </RootSiblingParent>
-          </ActiveVideoProvider>
+          <RootSiblingParent>
+            <React.Fragment
+              // Resets the entire tree below when it changes:
+              key={currentAccount?.did}>
+              <QueryProvider currentDid={currentAccount?.did}>
+                <StatsigProvider>
+                  <MessagesProvider>
+                    {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+                    <LabelDefsProvider>
+                      <ModerationOptsProvider>
+                        <LoggedOutViewProvider>
+                          <SelectedFeedProvider>
+                            <HiddenRepliesProvider>
+                              <UnreadNotifsProvider>
+                                <BackgroundNotificationPreferencesProvider>
+                                  <MutedThreadsProvider>
+                                    <ProgressGuideProvider>
+                                      <GestureHandlerRootView style={s.h100pct}>
+                                        <TestCtrls />
+                                        <Shell />
+                                        <NuxDialogs />
+                                      </GestureHandlerRootView>
+                                    </ProgressGuideProvider>
+                                  </MutedThreadsProvider>
+                                </BackgroundNotificationPreferencesProvider>
+                              </UnreadNotifsProvider>
+                            </HiddenRepliesProvider>
+                          </SelectedFeedProvider>
+                        </LoggedOutViewProvider>
+                      </ModerationOptsProvider>
+                    </LabelDefsProvider>
+                  </MessagesProvider>
+                </StatsigProvider>
+              </QueryProvider>
+            </React.Fragment>
+          </RootSiblingParent>
         </Splash>
       </ThemeProvider>
     </Alf>
@@ -159,8 +154,6 @@ function App() {
   const [isReady, setReady] = useState(false)
 
   React.useEffect(() => {
-    PlatformInfo.setAudioCategory(AudioCategory.Ambient)
-    PlatformInfo.setAudioActive(false)
     initPersistedState().then(() => setReady(true))
   }, [])
 
diff --git a/src/components/video/PlayButtonIcon.tsx b/src/components/video/PlayButtonIcon.tsx
index 90e93f744..8e0a6bb7a 100644
--- a/src/components/video/PlayButtonIcon.tsx
+++ b/src/components/video/PlayButtonIcon.tsx
@@ -4,7 +4,7 @@ import {View} from 'react-native'
 import {atoms as a, useTheme} from '#/alf'
 import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
 
-export function PlayButtonIcon({size = 36}: {size?: number}) {
+export function PlayButtonIcon({size = 32}: {size?: number}) {
   const t = useTheme()
   const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
   const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 60b467d62..b1bfd6715 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -1,8 +1,7 @@
-/* eslint-disable @typescript-eslint/no-shadow */
 import React from 'react'
 import {View} from 'react-native'
 import {ImagePickerAsset} from 'expo-image-picker'
-import {useVideoPlayer, VideoView} from 'expo-video'
+import {BlueskyVideoView} from '@haileyok/bluesky-video'
 
 import {CompressedVideo} from '#/lib/media/video/types'
 import {clamp} from '#/lib/numbers'
@@ -22,15 +21,8 @@ export function VideoPreview({
   clear: () => void
 }) {
   const t = useTheme()
+  const playerRef = React.useRef<BlueskyVideoView>(null)
   const autoplayDisabled = useAutoplayDisabled()
-  const player = useVideoPlayer(video.uri, player => {
-    player.loop = true
-    player.muted = true
-    if (!autoplayDisabled) {
-      player.play()
-    }
-  })
-
   let aspectRatio = asset.width / asset.height
 
   if (isNaN(aspectRatio)) {
@@ -50,12 +42,12 @@ export function VideoPreview({
         t.atoms.border_contrast_low,
         {backgroundColor: 'black'},
       ]}>
-      <VideoView
-        player={player}
-        style={a.flex_1}
-        allowsPictureInPicture={false}
-        nativeControls={false}
-        contentFit="contain"
+      <BlueskyVideoView
+        url={video.uri}
+        autoplay={autoplayDisabled}
+        beginMuted={true}
+        forceTakeover={true}
+        ref={playerRef}
       />
       <ExternalEmbedRemoveBtn onRemove={clear} />
       {autoplayDisabled && (
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 79dd2f491..f9aeae1a8 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -1,6 +1,7 @@
 import React, {memo} from 'react'
 import {FlatListProps, RefreshControl, ViewToken} from 'react-native'
 import {runOnJS, useSharedValue} from 'react-native-reanimated'
+import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
 
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {usePalette} from '#/lib/hooks/usePalette'
@@ -8,7 +9,6 @@ import {useScrollHandlers} from '#/lib/ScrollContext'
 import {useDedupe} from 'lib/hooks/useDedupe'
 import {addStyle} from 'lib/styles'
 import {isIOS} from 'platform/detection'
-import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
 import {FlatList_INTERNAL} from './Views'
 
 export type ListMethods = FlatList_INTERNAL
@@ -69,7 +69,7 @@ function ListImpl<ItemT>(
       onBeginDragFromContext?.(e, ctx)
     },
     onEndDrag(e, ctx) {
-      runOnJS(updateActiveViewAsync)()
+      runOnJS(updateActiveVideoViewAsync)()
       onEndDragFromContext?.(e, ctx)
     },
     onScroll(e, ctx) {
@@ -84,13 +84,13 @@ function ListImpl<ItemT>(
       }
 
       if (isIOS) {
-        runOnJS(dedupe)(updateActiveViewAsync)
+        runOnJS(dedupe)(updateActiveVideoViewAsync)
       }
     },
     // Note: adding onMomentumBegin here makes simulator scroll
     // lag on Android. So either don't add it, or figure out why.
     onMomentumEnd(e, ctx) {
-      runOnJS(updateActiveViewAsync)()
+      runOnJS(updateActiveVideoViewAsync)()
       onMomentumEndFromContext?.(e, ctx)
     },
   })
diff --git a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
deleted file mode 100644
index 95fa0bb0e..000000000
--- a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import React from 'react'
-import {useVideoPlayer, VideoPlayer} from 'expo-video'
-
-import {isAndroid, isNative} from '#/platform/detection'
-
-const Context = React.createContext<{
-  activeSource: string
-  activeViewId: string | undefined
-  setActiveSource: (src: string | null, viewId: string | null) => void
-  player: VideoPlayer
-} | null>(null)
-
-export function Provider({children}: {children: React.ReactNode}) {
-  if (!isNative) {
-    throw new Error('ActiveVideoProvider may only be used on native.')
-  }
-
-  const [activeSource, setActiveSource] = React.useState('')
-  const [activeViewId, setActiveViewId] = React.useState<string>()
-
-  const player = useVideoPlayer(activeSource, p => {
-    p.muted = true
-    p.loop = true
-    // We want to immediately call `play` so we get the loading state
-    p.play()
-  })
-
-  const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
-    // HACK
-    // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
-    // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
-    // apply it there.
-    if (src === activeSource && isAndroid) {
-      setActiveSource('')
-      setTimeout(() => {
-        setActiveSource(src ? src : '')
-      }, 100)
-    } else {
-      setActiveSource(src ? src : '')
-    }
-    setActiveViewId(viewId ? viewId : '')
-  }
-
-  return (
-    <Context.Provider
-      value={{
-        activeSource,
-        setActiveSource: setActiveSourceOuter,
-        activeViewId,
-        player,
-      }}>
-      {children}
-    </Context.Provider>
-  )
-}
-
-export function useActiveVideoNative() {
-  const context = React.useContext(Context)
-  if (!context) {
-    throw new Error(
-      'useActiveVideoNative must be used within a ActiveVideoNativeProvider',
-    )
-  }
-  return context
-}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index a672830db..267b5d184 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,22 +1,18 @@
-import React, {useCallback, useEffect, useId, useState} from 'react'
+import React, {useCallback, useState} from 'react'
 import {View} from 'react-native'
 import {ImageBackground} from 'expo-image'
-import {PlayerError, VideoPlayerStatus} from 'expo-video'
 import {AppBskyEmbedVideo} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {clamp} from '#/lib/numbers'
-import {useAutoplayDisabled} from 'state/preferences'
 import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
 import {atoms as a} from '#/alf'
 import {Button} from '#/components/Button'
-import {useIsWithinMessage} from '#/components/dms/MessageContext'
+import {useThrottledValue} from '#/components/hooks/useThrottledValue'
 import {Loader} from '#/components/Loader'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
-import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
 import {ErrorBoundary} from '../ErrorBoundary'
-import {useActiveVideoNative} from './ActiveVideoNativeContext'
 import * as VideoFallback from './VideoEmbedInner/VideoFallback'
 
 interface Props {
@@ -59,113 +55,36 @@ export function VideoEmbed({embed}: Props) {
 
 function InnerWrapper({embed}: Props) {
   const {_} = useLingui()
-  const {activeSource, activeViewId, setActiveSource, player} =
-    useActiveVideoNative()
-  const viewId = useId()
+  const ref = React.useRef<{togglePlayback: () => void}>(null)
 
-  const [playerStatus, setPlayerStatus] = useState<
-    VideoPlayerStatus | 'paused'
-  >('paused')
-  const [isMuted, setIsMuted] = useState(player.muted)
-  const [isFullscreen, setIsFullscreen] = React.useState(false)
-  const [timeRemaining, setTimeRemaining] = React.useState(0)
-  const isWithinMessage = useIsWithinMessage()
-  const disableAutoplay = useAutoplayDisabled() || isWithinMessage
-  const isActive = embed.playlist === activeSource && activeViewId === viewId
-  // There are some different loading states that we should pay attention to and show a spinner for
-  const isLoading =
-    isActive &&
-    (playerStatus === 'waitingToPlayAtSpecifiedRate' ||
-      playerStatus === 'loading')
-  // This happens whenever the visibility view decides that another video should start playing
-  const showOverlay = !isActive || isLoading || playerStatus === 'paused'
-
-  // send error up to error boundary
-  const [error, setError] = useState<Error | PlayerError | null>(null)
-  if (error) {
-    throw error
-  }
-
-  useEffect(() => {
-    if (isActive) {
-      // eslint-disable-next-line @typescript-eslint/no-shadow
-      const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
-        setIsMuted(isMuted)
-      })
-      const timeSub = player.addListener(
-        'timeRemainingChange',
-        secondsRemaining => {
-          setTimeRemaining(secondsRemaining)
-        },
-      )
-      const statusSub = player.addListener(
-        'statusChange',
-        (status, oldStatus, playerError) => {
-          setPlayerStatus(status)
-          if (status === 'error') {
-            setError(playerError ?? new Error('Unknown player error'))
-          }
-          if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') {
-            player.play()
-          }
-        },
-      )
-      return () => {
-        volumeSub.remove()
-        timeSub.remove()
-        statusSub.remove()
-      }
-    }
-  }, [player, isActive, disableAutoplay])
-
-  // The source might already be active (for example, if you are scrolling a list of quotes and its all the same
-  // video). In those cases, just start playing. Otherwise, setting the active source will result in the video
-  // start playback immediately
-  const startPlaying = (ignoreAutoplayPreference: boolean) => {
-    if (disableAutoplay && !ignoreAutoplayPreference) {
-      return
-    }
+  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)
 
-    if (isActive) {
-      player.play()
-    } else {
-      setActiveSource(embed.playlist, viewId)
-    }
-  }
+  const showOverlay =
+    !isActive ||
+    isLoading ||
+    (status === 'paused' && !isActive) ||
+    status === 'pending'
 
-  const onVisibilityStatusChange = (isVisible: boolean) => {
-    // When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change
-    // events
-    if (isFullscreen) {
-      return
+  React.useEffect(() => {
+    if (!isActive && status !== 'pending') {
+      setStatus('pending')
     }
-    if (isVisible) {
-      startPlaying(false)
-    } else {
-      // Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted
-      // until it gets replaced by another video
-      if (disableAutoplay) {
-        setActiveSource(null, null)
-      } else {
-        player.muted = true
-        if (player.playing) {
-          player.pause()
-        }
-      }
-    }
-  }
+  }, [isActive, status])
 
   return (
-    <VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}>
-      {isActive ? (
-        <VideoEmbedInnerNative
-          embed={embed}
-          timeRemaining={timeRemaining}
-          isMuted={isMuted}
-          isFullscreen={isFullscreen}
-          setIsFullscreen={setIsFullscreen}
-        />
-      ) : null}
+    <>
+      <VideoEmbedInnerNative
+        embed={embed}
+        setStatus={setStatus}
+        setIsLoading={setIsLoading}
+        setIsActive={setIsActive}
+        ref={ref}
+      />
       <ImageBackground
         source={{uri: embed.thumbnail}}
         accessibilityIgnoresInvertColors
@@ -185,17 +104,18 @@ function InnerWrapper({embed}: Props) {
       >
         <Button
           style={[a.flex_1, a.align_center, a.justify_center]}
-          onPress={() => startPlaying(true)}
+          onPress={() => {
+            ref.current?.togglePlayback()
+          }}
           label={_(msg`Play video`)}
           color="secondary">
-          {isLoading ? (
+          {showSpinner ? (
             <View
               style={[
                 a.rounded_full,
                 a.p_xs,
                 a.align_center,
                 a.justify_center,
-                {backgroundColor: 'rgba(0,0,0,0.5)'},
               ]}>
               <Loader size="2xl" style={{color: 'white'}} />
             </View>
@@ -204,7 +124,7 @@ function InnerWrapper({embed}: Props) {
           )}
         </Button>
       </ImageBackground>
-    </VisibilityView>
+    </>
   )
 }
 
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
index be3f90711..66e1df50d 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
@@ -1,4 +1,5 @@
 import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
 import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
 
 import {atoms as a, native, useTheme} from '#/alf'
@@ -8,7 +9,13 @@ import {Text} from '#/components/Typography'
  * Absolutely positioned time indicator showing how many seconds are remaining
  * Time is in seconds
  */
-export function TimeIndicator({time}: {time: number}) {
+export function TimeIndicator({
+  time,
+  style,
+}: {
+  time: number
+  style?: StyleProp<ViewStyle>
+}) {
   const t = useTheme()
 
   if (isNaN(time)) {
@@ -22,18 +29,20 @@ export function TimeIndicator({time}: {time: number}) {
     <Animated.View
       entering={native(FadeInDown.duration(300))}
       exiting={native(FadeOutDown.duration(500))}
+      pointerEvents="none"
       style={[
         {
           backgroundColor: 'rgba(0, 0, 0, 0.5)',
           borderRadius: 6,
           paddingHorizontal: 6,
           paddingVertical: 3,
-          position: 'absolute',
           left: 6,
           bottom: 6,
           minHeight: 21,
-          justifyContent: 'center',
         },
+        a.absolute,
+        a.justify_center,
+        style,
       ]}>
       <Text
         style={[
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
index 8ed7658a6..39ed990ab 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -1,137 +1,136 @@
-import React, {useCallback, useRef} from 'react'
-import {Pressable, View} from 'react-native'
+import React, {useRef} from 'react'
+import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
 import Animated, {FadeInDown} from 'react-native-reanimated'
-import {VideoPlayer, VideoView} from 'expo-video'
 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 {clamp} from '#/lib/numbers'
-import {isAndroid} from 'platform/detection'
-import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
+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 {
-  AudioCategory,
-  PlatformInfo,
-} from '../../../../../../modules/expo-bluesky-swiss-army'
 import {TimeIndicator} from './TimeIndicator'
 
-export function VideoEmbedInnerNative({
-  embed,
-  isFullscreen,
-  setIsFullscreen,
-  isMuted,
-  timeRemaining,
-}: {
-  embed: AppBskyEmbedVideo.View
-  isFullscreen: boolean
-  setIsFullscreen: (isFullscreen: boolean) => void
-  timeRemaining: number
-  isMuted: boolean
-}) {
-  const {_} = useLingui()
-  const {player} = useActiveVideoNative()
-  const ref = useRef<VideoView>(null)
+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 [isMuted, setIsMuted] = React.useState(true)
+    const [isPlaying, setIsPlaying] = React.useState(false)
+    const [timeRemaining, setTimeRemaining] = React.useState(0)
+    const [error, setError] = React.useState<string>()
 
-  const enterFullscreen = useCallback(() => {
-    ref.current?.enterFullscreen()
-  }, [])
+    React.useImperativeHandle(ref, () => ({
+      togglePlayback: () => {
+        videoRef.current?.togglePlayback()
+      },
+    }))
 
-  let aspectRatio = 16 / 9
+    if (error) {
+      throw new Error(error)
+    }
 
-  if (embed.aspectRatio) {
-    const {width, height} = embed.aspectRatio
-    aspectRatio = width / height
-    aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
-  }
+    let aspectRatio = 16 / 9
 
-  return (
-    <View style={[a.flex_1, a.relative, {aspectRatio}]}>
-      <VideoView
-        ref={ref}
-        player={player}
-        style={[a.flex_1, a.rounded_sm]}
-        contentFit="cover"
-        nativeControls={isFullscreen}
-        accessibilityIgnoresInvertColors
-        onFullscreenEnter={() => {
-          PlatformInfo.setAudioCategory(AudioCategory.Playback)
-          PlatformInfo.setAudioActive(true)
-          player.muted = false
-          setIsFullscreen(true)
-          if (isAndroid) {
-            player.play()
+    if (embed.aspectRatio) {
+      const {width, height} = embed.aspectRatio
+      aspectRatio = width / height
+      aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
+    }
+
+    return (
+      <View style={[a.flex_1, a.relative, {aspectRatio}]}>
+        <BlueskyVideoView
+          url={embed.playlist}
+          autoplay={!autoplayDisabled && !isWithinMessage}
+          beginMuted={true}
+          style={[a.rounded_sm]}
+          onActiveChange={e => {
+            setIsActive(e.nativeEvent.isActive)
+          }}
+          onLoadingChange={e => {
+            setIsLoading(e.nativeEvent.isLoading)
+          }}
+          onMutedChange={e => {
+            setIsMuted(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`)
           }
-        }}
-        onFullscreenExit={() => {
-          PlatformInfo.setAudioCategory(AudioCategory.Ambient)
-          PlatformInfo.setAudioActive(false)
-          player.muted = true
-          player.playbackRate = 1
-          setIsFullscreen(false)
-        }}
-        accessibilityLabel={
-          embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
-        }
-        accessibilityHint=""
-      />
-      <VideoControls
-        player={player}
-        enterFullscreen={enterFullscreen}
-        isMuted={isMuted}
-        timeRemaining={timeRemaining}
-      />
-      <MediaInsetBorder />
-    </View>
-  )
-}
+          accessibilityHint=""
+        />
+        <VideoControls
+          enterFullscreen={() => {
+            videoRef.current?.enterFullscreen()
+          }}
+          toggleMuted={() => {
+            videoRef.current?.toggleMuted()
+          }}
+          togglePlayback={() => {
+            videoRef.current?.togglePlayback()
+          }}
+          isMuted={isMuted}
+          isPlaying={isPlaying}
+          timeRemaining={timeRemaining}
+        />
+        <MediaInsetBorder />
+      </View>
+    )
+  },
+)
 
 function VideoControls({
-  player,
   enterFullscreen,
+  toggleMuted,
+  togglePlayback,
   timeRemaining,
+  isPlaying,
   isMuted,
 }: {
-  player: VideoPlayer
   enterFullscreen: () => void
+  toggleMuted: () => void
+  togglePlayback: () => void
   timeRemaining: number
+  isPlaying: boolean
   isMuted: boolean
 }) {
   const {_} = useLingui()
   const t = useTheme()
 
-  const onPressFullscreen = useCallback(() => {
-    switch (player.status) {
-      case 'idle':
-      case 'loading':
-      case 'readyToPlay': {
-        if (!player.playing) player.play()
-        enterFullscreen()
-        break
-      }
-      case 'error': {
-        player.replay()
-        break
-      }
-    }
-  }, [player, enterFullscreen])
-
-  const toggleMuted = useCallback(() => {
-    const muted = !player.muted
-    // We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when
-    // the video is muted, and vice versa.
-    const mix = !muted
-    const category = muted ? AudioCategory.Ambient : AudioCategory.Playback
-
-    PlatformInfo.setAudioCategory(category)
-    PlatformInfo.setAudioActive(mix)
-    player.muted = muted
-  }, [player])
-
   // show countdown when:
   // 1. timeRemaining is a number - was seeing NaNs
   // 2. duration is greater than 0 - means metadata has loaded
@@ -140,44 +139,80 @@ function VideoControls({
 
   return (
     <View style={[a.absolute, a.inset_0]}>
-      {showTime && <TimeIndicator time={timeRemaining} />}
       <Pressable
-        onPress={onPressFullscreen}
+        onPress={enterFullscreen}
         style={a.flex_1}
         accessibilityLabel={_(msg`Video`)}
         accessibilityHint={_(msg`Tap to enter full screen`)}
         accessibilityRole="button"
       />
-      <Animated.View
-        entering={FadeInDown.duration(300)}
-        style={[
-          a.absolute,
-          a.rounded_full,
-          a.justify_center,
-          {
-            backgroundColor: 'rgba(0, 0, 0, 0.5)',
-            paddingHorizontal: 4,
-            paddingVertical: 4,
-            bottom: 6,
-            right: 6,
-            minHeight: 21,
-            minWidth: 21,
-          },
-        ]}>
-        <Pressable
-          onPress={toggleMuted}
-          style={a.flex_1}
-          accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)}
-          accessibilityHint={_(msg`Tap to toggle sound`)}
-          accessibilityRole="button"
-          hitSlop={HITSLOP_30}>
-          {isMuted ? (
-            <MuteIcon width={13} fill={t.palette.white} />
-          ) : (
-            <UnmuteIcon width={13} fill={t.palette.white} />
-          )}
-        </Pressable>
-      </Animated.View>
+      <ControlButton
+        onPress={togglePlayback}
+        label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
+        accessibilityHint={_(msg`Tap to play or pause`)}
+        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={isMuted ? _(msg`Unmute`) : _(msg`Mute`)}
+        accessibilityHint={_(msg`Tap to toggle sound`)}
+        style={{right: 6}}>
+        {isMuted ? (
+          <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 (
+    <Animated.View
+      entering={FadeInDown.duration(300)}
+      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>
+    </Animated.View>
+  )
+}