about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-07-25 20:41:50 +0100
committerGitHub <noreply@github.com>2024-07-25 20:41:50 +0100
commit00240b95b90847f6691f7fa19c19f37d2ffc6624 (patch)
treeb543a07196f55db11a3eb1dc30cb604b2ce28ff6 /src
parent4ec999cab7104a381c8c7a3202ebb2d01599a513 (diff)
downloadvoidsky-00240b95b90847f6691f7fa19c19f37d2ffc6624.tar.zst
[Videos] Video player - PR #1 - basic player (#4731)
* add ffmpeg-kit-react-native

* get select video button + compression working

* up res to 1080p

* add progress component

* move logic out of compressVideo

* (WIP) add lonestar compression

* rework web compression a bit

* mess around with adding a thumbnail

* 3mbps

* replace

* use 3mbps

* add expo-video

* remove unnecessary try/catch

* rm ToastAndroid

* fix web

* wrap lazy component in suspense

* gate video select button

* rm web compression

* flip sign

* remove expo-video from web

* review nits

* add video picker permissions + rm temp buttons

* add ffmpeg-kit-react-native

* replace

* hls-capable player

* start trying to hoist up video player instance

* hoist video player and move things around

* always show native controls

* fix controls on expo video android

* gate temp video player in feed

* rm IS_DEV, doesn't do what I thought it did

* use __DEV__ instead

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx79
-rw-r--r--src/App.web.tsx73
-rw-r--r--src/components/icons/Play.tsx9
-rw-r--r--src/view/com/post/Post.tsx62
-rw-r--r--src/view/com/posts/FeedItem.tsx11
-rw-r--r--src/view/com/util/post-embeds/ActiveVideoContext.tsx48
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.tsx44
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner.tsx138
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner.web.tsx52
-rw-r--r--src/view/com/util/post-embeds/VideoPlayerContext.tsx41
-rw-r--r--src/view/com/util/post-embeds/VideoPlayerContext.web.tsx9
11 files changed, 461 insertions, 105 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index ed76c753b..d2c20fc8e 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -23,10 +23,12 @@ import {
 } from '#/lib/statsig/statsig'
 import {s} from '#/lib/styles'
 import {ThemeProvider} from '#/lib/ThemeContext'
+import I18nProvider from '#/locale/i18nProvider'
 import {logger} from '#/logger'
 import {Provider as A11yProvider} from '#/state/a11y'
 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
 import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {listenSessionDropped} from '#/state/events'
 import {Provider as InvitesStateProvider} from '#/state/invites'
 import {Provider as LightboxStateProvider} from '#/state/lightbox'
 import {MessagesProvider} from '#/state/messages'
@@ -49,6 +51,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {TestCtrls} from '#/view/com/testing/TestCtrls'
+import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
 import * as Toast from '#/view/com/util/Toast'
 import {Shell} from '#/view/shell'
 import {ThemeProvider as Alf} from '#/alf'
@@ -58,8 +61,6 @@ import {Provider as PortalProvider} from '#/components/Portal'
 import {Splash} from '#/Splash'
 import {Provider as TourProvider} from '#/tours'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
-import I18nProvider from './locale/i18nProvider'
-import {listenSessionDropped} from './state/events'
 
 SplashScreen.preventAutoHideAsync()
 
@@ -107,42 +108,44 @@ function InnerApp() {
       <Alf theme={theme}>
         <ThemeProvider theme={theme}>
           <Splash isReady={isReady && hasCheckedReferrer}>
-            <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>
-                              <UnreadNotifsProvider>
-                                <BackgroundNotificationPreferencesProvider>
-                                  <MutedThreadsProvider>
-                                    <TourProvider>
-                                      <ProgressGuideProvider>
-                                        <GestureHandlerRootView
-                                          style={s.h100pct}>
-                                          <TestCtrls />
-                                          <Shell />
-                                        </GestureHandlerRootView>
-                                      </ProgressGuideProvider>
-                                    </TourProvider>
-                                  </MutedThreadsProvider>
-                                </BackgroundNotificationPreferencesProvider>
-                              </UnreadNotifsProvider>
-                            </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>
+                                <UnreadNotifsProvider>
+                                  <BackgroundNotificationPreferencesProvider>
+                                    <MutedThreadsProvider>
+                                      <TourProvider>
+                                        <ProgressGuideProvider>
+                                          <GestureHandlerRootView
+                                            style={s.h100pct}>
+                                            <TestCtrls />
+                                            <Shell />
+                                          </GestureHandlerRootView>
+                                        </ProgressGuideProvider>
+                                      </TourProvider>
+                                    </MutedThreadsProvider>
+                                  </BackgroundNotificationPreferencesProvider>
+                                </UnreadNotifsProvider>
+                              </SelectedFeedProvider>
+                            </LoggedOutViewProvider>
+                          </ModerationOptsProvider>
+                        </LabelDefsProvider>
+                      </MessagesProvider>
+                    </StatsigProvider>
+                  </QueryProvider>
+                </React.Fragment>
+              </RootSiblingParent>
+            </ActiveVideoProvider>
           </Splash>
         </ThemeProvider>
       </Alf>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index a64988f38..df6fbf244 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -12,10 +12,12 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
 import {QueryProvider} from '#/lib/react-query'
 import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
 import {ThemeProvider} from '#/lib/ThemeContext'
+import I18nProvider from '#/locale/i18nProvider'
 import {logger} from '#/logger'
 import {Provider as A11yProvider} from '#/state/a11y'
 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
 import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {listenSessionDropped} from '#/state/events'
 import {Provider as InvitesStateProvider} from '#/state/invites'
 import {Provider as LightboxStateProvider} from '#/state/lightbox'
 import {MessagesProvider} from '#/state/messages'
@@ -37,6 +39,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
+import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
 import * as Toast from '#/view/com/util/Toast'
 import {ToastContainer} from '#/view/com/util/Toast.web'
 import {Shell} from '#/view/shell/index'
@@ -46,8 +49,6 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
 import {Provider as PortalProvider} from '#/components/Portal'
 import {Provider as TourProvider} from '#/tours'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
-import I18nProvider from './locale/i18nProvider'
-import {listenSessionDropped} from './state/events'
 
 function InnerApp() {
   const [isReady, setIsReady] = React.useState(false)
@@ -92,39 +93,41 @@ function InnerApp() {
       <Alf theme={theme}>
         <ThemeProvider theme={theme}>
           <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>
-                            <UnreadNotifsProvider>
-                              <BackgroundNotificationPreferencesProvider>
-                                <MutedThreadsProvider>
-                                  <SafeAreaProvider>
-                                    <TourProvider>
-                                      <ProgressGuideProvider>
-                                        <Shell />
-                                      </ProgressGuideProvider>
-                                    </TourProvider>
-                                  </SafeAreaProvider>
-                                </MutedThreadsProvider>
-                              </BackgroundNotificationPreferencesProvider>
-                            </UnreadNotifsProvider>
-                          </SelectedFeedProvider>
-                        </LoggedOutViewProvider>
-                      </ModerationOptsProvider>
-                    </LabelDefsProvider>
-                  </MessagesProvider>
-                </StatsigProvider>
-              </QueryProvider>
-            </React.Fragment>
-            <ToastContainer />
+            <ActiveVideoProvider>
+              <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>
+                              <UnreadNotifsProvider>
+                                <BackgroundNotificationPreferencesProvider>
+                                  <MutedThreadsProvider>
+                                    <SafeAreaProvider>
+                                      <TourProvider>
+                                        <ProgressGuideProvider>
+                                          <Shell />
+                                        </ProgressGuideProvider>
+                                      </TourProvider>
+                                    </SafeAreaProvider>
+                                  </MutedThreadsProvider>
+                                </BackgroundNotificationPreferencesProvider>
+                              </UnreadNotifsProvider>
+                            </SelectedFeedProvider>
+                          </LoggedOutViewProvider>
+                        </ModerationOptsProvider>
+                      </LabelDefsProvider>
+                    </MessagesProvider>
+                  </StatsigProvider>
+                </QueryProvider>
+              </React.Fragment>
+              <ToastContainer />
+            </ActiveVideoProvider>
           </RootSiblingParent>
         </ThemeProvider>
       </Alf>
diff --git a/src/components/icons/Play.tsx b/src/components/icons/Play.tsx
new file mode 100644
index 000000000..acf421d57
--- /dev/null
+++ b/src/components/icons/Play.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
+  path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
+})
+
+export const Play_Filled_Corner2_Rounded = createSinglePathSVG({
+  path: 'M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z',
+})
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index a05339d4d..425a2257f 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -210,38 +210,40 @@ function PostInner({
             </View>
           )}
           <LabelsOnMyPost post={post} />
-          <ContentHider
-            modui={moderation.ui('contentView')}
-            style={styles.contentHider}
-            childContainerStyle={styles.contentHiderChild}>
-            <PostAlerts
+          {false && (
+            <ContentHider
               modui={moderation.ui('contentView')}
-              style={[a.py_xs]}
-            />
-            {richText.text ? (
-              <View style={styles.postTextContainer}>
-                <RichText
-                  enableTags
-                  testID="postText"
-                  value={richText}
-                  numberOfLines={limitLines ? MAX_POST_LINES : undefined}
-                  style={[a.flex_1, a.text_md]}
-                  authorHandle={post.author.handle}
-                />
-              </View>
-            ) : undefined}
-            {limitLines ? (
-              <TextLink
-                text={_(msg`Show More`)}
-                style={pal.link}
-                onPress={onPressShowMore}
-                href="#"
+              style={styles.contentHider}
+              childContainerStyle={styles.contentHiderChild}>
+              <PostAlerts
+                modui={moderation.ui('contentView')}
+                style={[a.py_xs]}
               />
-            ) : undefined}
-            {post.embed ? (
-              <PostEmbeds embed={post.embed} moderation={moderation} />
-            ) : null}
-          </ContentHider>
+              {richText.text ? (
+                <View style={styles.postTextContainer}>
+                  <RichText
+                    enableTags
+                    testID="postText"
+                    value={richText}
+                    numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+                    style={[a.flex_1, a.text_md]}
+                    authorHandle={post.author.handle}
+                  />
+                </View>
+              ) : undefined}
+              {limitLines ? (
+                <TextLink
+                  text={_(msg`Show More`)}
+                  style={pal.link}
+                  onPress={onPressShowMore}
+                  href="#"
+                />
+              ) : undefined}
+              {post.embed ? (
+                <PostEmbeds embed={post.embed} moderation={moderation} />
+              ) : null}
+            </ContentHider>
+          )}
           <PostCtrls
             post={post}
             record={record}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index dbc5796db..b5e208011 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -16,8 +16,10 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
+import {useGate} from '#/lib/statsig/statsig'
 import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
 import {useFeedFeedbackContext} from '#/state/feed-feedback'
+import {useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
 import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
 import {MAX_POST_LINES} from 'lib/constants'
@@ -29,6 +31,7 @@ import {countLines} from 'lib/strings/helpers'
 import {s} from 'lib/styles'
 import {precacheProfile} from 'state/queries/profile'
 import {atoms as a} from '#/alf'
+import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
 import {ContentHider} from '#/components/moderation/ContentHider'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
 import {RichText} from '#/components/RichText'
@@ -38,13 +41,12 @@ import {FeedNameText} from '../util/FeedInfoText'
 import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostEmbeds} from '../util/post-embeds'
+import {VideoEmbed} from '../util/post-embeds/VideoEmbed'
 import {PostMeta} from '../util/PostMeta'
 import {Text} from '../util/text/Text'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {AviFollowButton} from './AviFollowButton'
 import hairlineWidth = StyleSheet.hairlineWidth
-import {useSession} from '#/state/session'
-import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
 
 interface FeedItemProps {
   record: AppBskyFeedPost.Record
@@ -136,6 +138,8 @@ let FeedItemInner = ({
   const {openComposer} = useComposerControls()
   const pal = usePalette('default')
   const {_} = useLingui()
+  const gate = useGate()
+
   const href = useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey)
@@ -354,6 +358,9 @@ let FeedItemInner = ({
             postAuthor={post.author}
             onOpenEmbed={onOpenEmbed}
           />
+          {__DEV__ && gate('videos') && (
+            <VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
+          )}
           <PostCtrls
             post={post}
             record={record}
diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoContext.tsx
new file mode 100644
index 000000000..6804436a7
--- /dev/null
+++ b/src/view/com/util/post-embeds/ActiveVideoContext.tsx
@@ -0,0 +1,48 @@
+import React, {useCallback, useId, useMemo, useState} from 'react'
+
+import {VideoPlayerProvider} from './VideoPlayerContext'
+
+const ActiveVideoContext = React.createContext<{
+  activeViewId: string | null
+  setActiveView: (viewId: string, src: string) => void
+} | null>(null)
+
+export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
+  const [activeViewId, setActiveViewId] = useState<string | null>(null)
+  const [source, setSource] = useState<string | null>(null)
+
+  const value = useMemo(
+    () => ({
+      activeViewId,
+      setActiveView: (viewId: string, src: string) => {
+        setActiveViewId(viewId)
+        setSource(src)
+      },
+    }),
+    [activeViewId],
+  )
+
+  return (
+    <ActiveVideoContext.Provider value={value}>
+      <VideoPlayerProvider source={source ?? ''} viewId={activeViewId}>
+        {children}
+      </VideoPlayerProvider>
+    </ActiveVideoContext.Provider>
+  )
+}
+
+export function useActiveVideoView() {
+  const context = React.useContext(ActiveVideoContext)
+  if (!context) {
+    throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
+  }
+  const id = useId()
+
+  return {
+    active: context.activeViewId === id,
+    setActive: useCallback(
+      (source: string) => context.setActiveView(id, source),
+      [context, id],
+    ),
+  }
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
new file mode 100644
index 000000000..5e5293a55
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -0,0 +1,44 @@
+import React, {useCallback} from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
+import {useActiveVideoView} from './ActiveVideoContext'
+import {VideoEmbedInner} from './VideoEmbedInner'
+
+export function VideoEmbed({source}: {source: string}) {
+  const t = useTheme()
+  const {active, setActive} = useActiveVideoView()
+  const {_} = useLingui()
+
+  const onPress = useCallback(() => setActive(source), [setActive, source])
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.rounded_sm,
+        {aspectRatio: 16 / 9},
+        a.overflow_hidden,
+        t.atoms.bg_contrast_25,
+        a.my_xs,
+      ]}>
+      {active ? (
+        <VideoEmbedInner source={source} />
+      ) : (
+        <Button
+          style={[a.flex_1, t.atoms.bg_contrast_25]}
+          onPress={onPress}
+          label={_(msg`Play video`)}
+          variant="ghost"
+          color="secondary"
+          size="large">
+          <ButtonIcon icon={PlayIcon} />
+        </Button>
+      )}
+    </View>
+  )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
new file mode 100644
index 000000000..ef0678709
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
@@ -0,0 +1,138 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react'
+import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
+import Animated, {
+  measure,
+  runOnJS,
+  useAnimatedRef,
+  useFrameCallback,
+  useSharedValue,
+} from 'react-native-reanimated'
+import {VideoPlayer, VideoView} from 'expo-video'
+
+import {atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {useVideoPlayer} from './VideoPlayerContext'
+
+export const VideoEmbedInner = ({}: {source: string}) => {
+  const player = useVideoPlayer()
+  const aref = useAnimatedRef<Animated.View>()
+  const {height: windowHeight} = useWindowDimensions()
+  const hasLeftView = useSharedValue(false)
+  const ref = useRef<VideoView>(null)
+
+  const onEnterView = useCallback(() => {
+    if (player.status === 'readyToPlay') {
+      player.play()
+    }
+  }, [player])
+
+  const onLeaveView = useCallback(() => {
+    player.pause()
+  }, [player])
+
+  const enterFullscreen = useCallback(() => {
+    if (ref.current) {
+      ref.current.enterFullscreen()
+    }
+  }, [])
+
+  useFrameCallback(() => {
+    const measurement = measure(aref)
+
+    if (measurement) {
+      if (hasLeftView.value) {
+        // Check if the video is in view
+        if (
+          measurement.pageY >= 0 &&
+          measurement.pageY + measurement.height <= windowHeight
+        ) {
+          runOnJS(onEnterView)()
+          hasLeftView.value = false
+        }
+      } else {
+        // Check if the video is out of view
+        if (
+          measurement.pageY + measurement.height < 0 ||
+          measurement.pageY > windowHeight
+        ) {
+          runOnJS(onLeaveView)()
+          hasLeftView.value = true
+        }
+      }
+    }
+  })
+
+  return (
+    <Animated.View
+      style={[a.flex_1, a.relative]}
+      ref={aref}
+      collapsable={false}>
+      <VideoView
+        ref={ref}
+        player={player}
+        style={a.flex_1}
+        nativeControls={true}
+      />
+      <VideoControls player={player} enterFullscreen={enterFullscreen} />
+    </Animated.View>
+  )
+}
+
+function VideoControls({
+  player,
+  enterFullscreen,
+}: {
+  player: VideoPlayer
+  enterFullscreen: () => void
+}) {
+  const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime))
+
+  useEffect(() => {
+    const interval = setInterval(() => {
+      setCurrentTime(Math.floor(player.duration - player.currentTime))
+      // how often should we update the time?
+      // 1000 gets out of sync with the video time
+    }, 250)
+
+    return () => {
+      clearInterval(interval)
+    }
+  }, [player])
+
+  const minutes = Math.floor(currentTime / 60)
+  const seconds = String(currentTime % 60).padStart(2, '0')
+
+  return (
+    <View style={[a.absolute, a.inset_0]}>
+      <View style={styles.timeContainer} pointerEvents="none">
+        <Text style={styles.timeElapsed}>
+          {minutes}:{seconds}
+        </Text>
+      </View>
+      <Pressable
+        onPress={enterFullscreen}
+        style={a.flex_1}
+        accessibilityLabel="Video"
+        accessibilityHint="Tap to enter full screen"
+        accessibilityRole="button"
+      />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  timeContainer: {
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderRadius: 6,
+    paddingHorizontal: 6,
+    paddingVertical: 3,
+    position: 'absolute',
+    left: 5,
+    bottom: 5,
+  },
+  timeElapsed: {
+    color: 'white',
+    fontSize: 12,
+    fontWeight: 'bold',
+  },
+})
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
new file mode 100644
index 000000000..cb02743c6
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
@@ -0,0 +1,52 @@
+import React, {useEffect, useRef} from 'react'
+import Hls from 'hls.js'
+
+import {atoms as a} from '#/alf'
+
+export const VideoEmbedInner = ({source}: {source: string}) => {
+  const ref = useRef<HTMLVideoElement>(null)
+
+  // Use HLS.js to play HLS video
+  useEffect(() => {
+    if (ref.current) {
+      if (ref.current.canPlayType('application/vnd.apple.mpegurl')) {
+        ref.current.src = source
+      } else if (Hls.isSupported()) {
+        var hls = new Hls()
+        hls.loadSource(source)
+        hls.attachMedia(ref.current)
+      } else {
+        // TODO: fallback
+      }
+    }
+  }, [source])
+
+  useEffect(() => {
+    if (ref.current) {
+      const observer = new IntersectionObserver(
+        ([entry]) => {
+          if (ref.current) {
+            if (entry.isIntersecting) {
+              if (ref.current.paused) {
+                ref.current.play()
+              }
+            } else {
+              if (!ref.current.paused) {
+                ref.current.pause()
+              }
+            }
+          }
+        },
+        {threshold: 0},
+      )
+
+      observer.observe(ref.current)
+
+      return () => {
+        observer.disconnect()
+      }
+    }
+  }, [])
+
+  return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop />
+}
diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
new file mode 100644
index 000000000..bc5d9d370
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
@@ -0,0 +1,41 @@
+import React, {useContext, useEffect} from 'react'
+import type {VideoPlayer} from 'expo-video'
+import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
+
+const VideoPlayerContext = React.createContext<VideoPlayer | null>(null)
+
+export function VideoPlayerProvider({
+  viewId,
+  source,
+  children,
+}: {
+  viewId: string | null
+  source: string
+  children: React.ReactNode
+}) {
+  // eslint-disable-next-line @typescript-eslint/no-shadow
+  const player = useExpoVideoPlayer(source, player => {
+    player.loop = true
+    player.play()
+  })
+
+  // make sure we're playing every time the viewId changes
+  // this means the video is different
+  useEffect(() => {
+    player.play()
+  }, [viewId, player])
+
+  return (
+    <VideoPlayerContext.Provider value={player}>
+      {children}
+    </VideoPlayerContext.Provider>
+  )
+}
+
+export function useVideoPlayer() {
+  const context = useContext(VideoPlayerContext)
+  if (!context) {
+    throw new Error('useVideoPlayer must be used within a VideoPlayerProvider')
+  }
+  return context
+}
diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx
new file mode 100644
index 000000000..329fb1206
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx
@@ -0,0 +1,9 @@
+import React from 'react'
+
+export function VideoPlayerProvider({children}: {children: React.ReactNode}) {
+  return children
+}
+
+export function useVideoPlayer() {
+  throw new Error('useVideoPlayer must not be used on web')
+}