about summary refs log tree commit diff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/Profile/ProfileFeed/index.tsx35
-rw-r--r--src/screens/Search/components/ExploreTrendingVideos.tsx271
-rw-r--r--src/screens/Settings/ContentAndMediaSettings.tsx26
-rw-r--r--src/screens/VideoFeed/components/Header.tsx180
-rw-r--r--src/screens/VideoFeed/components/Scrubber.tsx265
-rw-r--r--src/screens/VideoFeed/index.tsx1093
-rw-r--r--src/screens/VideoFeed/index.web.tsx3
-rw-r--r--src/screens/VideoFeed/types.ts18
8 files changed, 1886 insertions, 5 deletions
diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx
index 3a8686a7d..8751ba3d9 100644
--- a/src/screens/Profile/ProfileFeed/index.tsx
+++ b/src/screens/Profile/ProfileFeed/index.tsx
@@ -1,12 +1,14 @@
 import React, {useCallback, useMemo} from 'react'
 import {StyleSheet, View} from 'react-native'
 import {useAnimatedRef} from 'react-native-reanimated'
+import {AppBskyFeedDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useIsFocused, useNavigation} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {useQueryClient} from '@tanstack/react-query'
 
+import {VIDEO_FEED_URIS} from '#/lib/constants'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useSetTitle} from '#/lib/hooks/useSetTitle'
 import {ComposeIcon2} from '#/lib/icons'
@@ -18,7 +20,7 @@ import {isNative} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
 import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
-import {FeedDescriptor} from '#/state/queries/post-feed'
+import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {
   usePreferencesQuery,
@@ -46,6 +48,11 @@ type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
 export function ProfileFeedScreen(props: Props) {
   const {rkey, name: handleOrDid} = props.route.params
 
+  const feedParams: FeedParams | undefined = props.route.params.feedCacheKey
+    ? {
+        feedCacheKey: props.route.params.feedCacheKey,
+      }
+    : undefined
   const pal = usePalette('default')
   const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
@@ -96,7 +103,10 @@ export function ProfileFeedScreen(props: Props) {
 
   return resolvedUri ? (
     <Layout.Screen>
-      <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
+      <ProfileFeedScreenIntermediate
+        feedUri={resolvedUri.uri}
+        feedParams={feedParams}
+      />
     </Layout.Screen>
   ) : (
     <Layout.Screen>
@@ -108,7 +118,13 @@ export function ProfileFeedScreen(props: Props) {
   )
 }
 
-function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
+function ProfileFeedScreenIntermediate({
+  feedUri,
+  feedParams,
+}: {
+  feedUri: string
+  feedParams: FeedParams | undefined
+}) {
   const {data: preferences} = usePreferencesQuery()
   const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
 
@@ -125,15 +141,18 @@ function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
     <ProfileFeedScreenInner
       preferences={preferences}
       feedInfo={info as FeedSourceFeedInfo}
+      feedParams={feedParams}
     />
   )
 }
 
 export function ProfileFeedScreenInner({
   feedInfo,
+  feedParams,
 }: {
   preferences: UsePreferencesQueryResponse
   feedInfo: FeedSourceFeedInfo
+  feedParams: FeedParams | undefined
 }) {
   const {_} = useLingui()
   const {hasSession} = useSession()
@@ -170,6 +189,14 @@ export function ProfileFeedScreenInner({
     return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
   }, [_])
 
+  const isVideoFeed = React.useMemo(() => {
+    const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
+    const feedIsVideoMode =
+      feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO
+    const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode
+    return isNative && _isVideoFeed
+  }, [feedInfo])
+
   return (
     <>
       <ProfileFeedHeader info={feedInfo} />
@@ -177,12 +204,14 @@ export function ProfileFeedScreenInner({
       <FeedFeedbackProvider value={feedFeedback}>
         <PostFeed
           feed={feed}
+          feedParams={feedParams}
           pollInterval={60e3}
           disablePoll={hasNew}
           onHasNew={setHasNew}
           scrollElRef={scrollElRef}
           onScrolledDownChange={setIsScrolledDown}
           renderEmptyState={renderPostsEmpty}
+          isVideoFeed={isVideoFeed}
         />
       </FeedFeedbackProvider>
 
diff --git a/src/screens/Search/components/ExploreTrendingVideos.tsx b/src/screens/Search/components/ExploreTrendingVideos.tsx
new file mode 100644
index 000000000..daceb9acd
--- /dev/null
+++ b/src/screens/Search/components/ExploreTrendingVideos.tsx
@@ -0,0 +1,271 @@
+import React from 'react'
+import {ScrollView, View} from 'react-native'
+import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {VIDEO_FEED_URI} from '#/lib/constants'
+import {makeCustomFeedLink} from '#/lib/routes/links'
+import {logEvent} from '#/lib/statsig/statsig'
+import {isWeb} from '#/platform/detection'
+import {useSavedFeeds} from '#/state/queries/feed'
+import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
+import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
+import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
+import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {GradientFill} from '#/components/GradientFill'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin'
+import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {
+  CompactVideoPostCard,
+  CompactVideoPostCardPlaceholder,
+} from '#/components/VideoPostCard'
+
+const CARD_WIDTH = 100
+
+const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
+const FEED_PARAMS: {
+  feedCacheKey: 'explore'
+} = {
+  feedCacheKey: 'explore',
+}
+
+export function ExploreTrendingVideos() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const gutters = useGutters([0, 'base'])
+  const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
+
+  // Refetch on tab change if nothing else is using this query.
+  const queryClient = useQueryClient()
+  useFocusEffect(() => {
+    return () => {
+      const query = queryClient
+        .getQueryCache()
+        .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
+      if (query && query.getObserversCount() <= 1) {
+        query.fetch()
+      }
+    }
+  })
+
+  const {data: saved} = useSavedFeeds()
+  const isSavedAlready = React.useMemo(() => {
+    return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
+  }, [saved])
+
+  const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
+    useAddSavedFeedsMutation()
+  const pinFeed = React.useCallback(
+    (e: any) => {
+      e.preventDefault()
+
+      addSavedFeeds([
+        {
+          type: 'feed',
+          value: VIDEO_FEED_URI,
+          pinned: true,
+        },
+      ])
+
+      // prevent navigation
+      return false
+    },
+    [addSavedFeeds],
+  )
+
+  if (error) {
+    return null
+  }
+
+  return (
+    <View style={[a.pb_xl]}>
+      <View
+        style={[
+          a.flex_row,
+          isWeb
+            ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
+            : [a.p_lg, a.pt_xl, a.gap_md],
+          a.border_b,
+          t.atoms.border_contrast_low,
+        ]}>
+        <View style={[a.flex_1, a.gap_sm]}>
+          <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+            <Graph
+              size="lg"
+              fill={t.palette.primary_500}
+              style={{marginLeft: -2}}
+            />
+            <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
+              <Trans>Trending Videos</Trans>
+            </Text>
+            <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
+              <GradientFill gradient={tokens.gradients.primary} />
+              <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
+                <Trans>BETA</Trans>
+              </Text>
+            </View>
+          </View>
+          <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
+            <Trans>Popular videos in your network.</Trans>
+          </Text>
+        </View>
+      </View>
+
+      <BlockDrawerGesture>
+        <ScrollView
+          horizontal
+          showsHorizontalScrollIndicator={false}
+          decelerationRate="fast"
+          snapToInterval={CARD_WIDTH + tokens.space.sm}>
+          <View
+            style={[
+              a.pt_lg,
+              a.flex_row,
+              a.gap_sm,
+              {
+                paddingLeft: gutters.paddingLeft,
+                paddingRight: gutters.paddingRight,
+              },
+            ]}>
+            {isLoading ? (
+              Array(10)
+                .fill(0)
+                .map((_, i) => (
+                  <View key={i} style={[{width: CARD_WIDTH}]}>
+                    <CompactVideoPostCardPlaceholder />
+                  </View>
+                ))
+            ) : error || !data ? (
+              <Text>
+                <Trans>Whoops! Trending videos failed to load.</Trans>
+              </Text>
+            ) : (
+              <VideoCards data={data} />
+            )}
+          </View>
+        </ScrollView>
+      </BlockDrawerGesture>
+
+      {!isSavedAlready && (
+        <View
+          style={[
+            gutters,
+            a.pt_lg,
+            a.flex_row,
+            a.align_center,
+            a.justify_between,
+            a.gap_xl,
+          ]}>
+          <Text style={[a.flex_1, a.text_sm, a.leading_snug]}>
+            <Trans>
+              Pin the trending videos feed to your home screen for easy access
+            </Trans>
+          </Text>
+          <Button
+            disabled={isPinPending}
+            label={_(msg`Pin`)}
+            size="small"
+            variant="outline"
+            color="secondary"
+            onPress={pinFeed}>
+            <ButtonText>{_(msg`Pin`)}</ButtonText>
+            <ButtonIcon icon={Pin} position="right" />
+          </Button>
+        </View>
+      )}
+    </View>
+  )
+}
+
+function VideoCards({
+  data,
+}: {
+  data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const items = React.useMemo(() => {
+    return data.pages
+      .flatMap(page => page.slices)
+      .map(slice => slice.items[0])
+      .filter(Boolean)
+      .filter(item => AppBskyEmbedVideo.isView(item.post.embed))
+      .slice(0, 8)
+  }, [data])
+  const href = React.useMemo(() => {
+    const urip = new AtUri(VIDEO_FEED_URI)
+    return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
+  }, [])
+
+  return (
+    <>
+      {items.map(item => (
+        <View key={item.post.uri} style={[{width: CARD_WIDTH}]}>
+          <CompactVideoPostCard
+            post={item.post}
+            moderation={item.moderation}
+            sourceContext={{
+              type: 'feedgen',
+              uri: VIDEO_FEED_URI,
+              sourceInterstitial: 'explore',
+            }}
+            onInteract={() => {
+              logEvent('videoCard:click', {
+                context: 'interstitial:discover',
+              })
+            }}
+          />
+        </View>
+      ))}
+
+      <View style={[{width: CARD_WIDTH * 2}]}>
+        <Link
+          to={href}
+          label={_(msg`View more`)}
+          style={[
+            a.justify_center,
+            a.align_center,
+            a.flex_1,
+            a.rounded_md,
+            t.atoms.bg_contrast_25,
+          ]}>
+          {({pressed}) => (
+            <View
+              style={[
+                a.flex_row,
+                a.align_center,
+                a.gap_md,
+                {
+                  opacity: pressed ? 0.6 : 1,
+                },
+              ]}>
+              <Text style={[a.text_md]}>
+                <Trans>View more</Trans>
+              </Text>
+              <View
+                style={[
+                  a.align_center,
+                  a.justify_center,
+                  a.rounded_full,
+                  {
+                    width: 34,
+                    height: 34,
+                    backgroundColor: t.palette.primary_500,
+                  },
+                ]}>
+                <ButtonIcon icon={ChevronRight} />
+              </View>
+            </View>
+          )}
+        </Link>
+      </View>
+    </>
+  )
+}
diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx
index 4a9354bb8..e28c98803 100644
--- a/src/screens/Settings/ContentAndMediaSettings.tsx
+++ b/src/screens/Settings/ContentAndMediaSettings.tsx
@@ -37,8 +37,9 @@ export function ContentAndMediaSettingsScreen({}: Props) {
   const inAppBrowserPref = useInAppBrowser()
   const setUseInAppBrowser = useSetInAppBrowser()
   const {enabled: trendingEnabled} = useTrendingConfig()
-  const {trendingDisabled} = useTrendingSettings()
-  const {setTrendingDisabled} = useTrendingSettingsApi()
+  const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
+  const {setTrendingDisabled, setTrendingVideoDisabled} =
+    useTrendingSettingsApi()
 
   return (
     <Layout.Screen>
@@ -138,6 +139,27 @@ export function ContentAndMediaSettingsScreen({}: Props) {
                   <Toggle.Platform />
                 </SettingsList.Item>
               </Toggle.Item>
+              <Toggle.Item
+                name="show_trending_videos"
+                label={_(msg`Enable trending videos in your Discover feed.`)}
+                value={!trendingVideoDisabled}
+                onChange={value => {
+                  const hide = Boolean(!value)
+                  if (hide) {
+                    logEvent('trendingVideos:hide', {context: 'settings'})
+                  } else {
+                    logEvent('trendingVideos:show', {context: 'settings'})
+                  }
+                  setTrendingVideoDisabled(hide)
+                }}>
+                <SettingsList.Item>
+                  <SettingsList.ItemIcon icon={Graph} />
+                  <SettingsList.ItemText>
+                    <Trans>Enable trending videos in your Discover feed</Trans>
+                  </SettingsList.ItemText>
+                  <Toggle.Platform />
+                </SettingsList.Item>
+              </Toggle.Item>
             </>
           )}
         </SettingsList.Container>
diff --git a/src/screens/VideoFeed/components/Header.tsx b/src/screens/VideoFeed/components/Header.tsx
new file mode 100644
index 000000000..66c932119
--- /dev/null
+++ b/src/screens/VideoFeed/components/Header.tsx
@@ -0,0 +1,180 @@
+import {useCallback} from 'react'
+import {GestureResponderEvent, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {HITSLOP_30} from '#/lib/constants'
+import {NavigationProp} from '#/lib/routes/types'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useFeedSourceInfoQuery} from '#/state/queries/feed'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {Button, ButtonProps} from '#/components/Button'
+import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
+import * as Layout from '#/components/Layout'
+import {BUTTON_VISUAL_ALIGNMENT_OFFSET} from '#/components/Layout/const'
+import {Text} from '#/components/Typography'
+
+export function HeaderPlaceholder() {
+  return (
+    <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+      <View
+        style={[
+          a.rounded_sm,
+          {
+            width: 36,
+            height: 36,
+            backgroundColor: 'white',
+            opacity: 0.8,
+          },
+        ]}
+      />
+
+      <View style={[a.flex_1, a.gap_xs]}>
+        <View
+          style={[
+            a.w_full,
+            a.rounded_xs,
+            {
+              backgroundColor: 'white',
+              height: 14,
+              width: 80,
+              opacity: 0.8,
+            },
+          ]}
+        />
+        <View
+          style={[
+            a.w_full,
+            a.rounded_xs,
+            {
+              backgroundColor: 'white',
+              height: 10,
+              width: 140,
+              opacity: 0.6,
+            },
+          ]}
+        />
+      </View>
+    </View>
+  )
+}
+
+export function Header({
+  sourceContext,
+}: {
+  sourceContext: VideoFeedSourceContext
+}) {
+  let content = null
+  switch (sourceContext.type) {
+    case 'feedgen': {
+      content = <FeedHeader sourceContext={sourceContext} />
+      break
+    }
+    case 'author':
+    // TODO
+    default: {
+      break
+    }
+  }
+
+  return (
+    <Layout.Header.Outer noBottomBorder>
+      <BackButton />
+      <Layout.Header.Content align="left">{content}</Layout.Header.Content>
+    </Layout.Header.Outer>
+  )
+}
+
+export function FeedHeader({
+  sourceContext,
+}: {
+  sourceContext: Exclude<VideoFeedSourceContext, {type: 'author'}>
+}) {
+  const {gtMobile} = useBreakpoints()
+
+  const {
+    data: info,
+    isLoading,
+    error,
+  } = useFeedSourceInfoQuery({uri: sourceContext.uri})
+
+  if (sourceContext.sourceInterstitial !== undefined) {
+    // For now, don't show the header if coming from an interstitial.
+    return null
+  }
+
+  if (isLoading) {
+    return <HeaderPlaceholder />
+  } else if (error || !info) {
+    return null
+  }
+
+  return (
+    <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+      {info.avatar && <UserAvatar size={36} type="algo" avatar={info.avatar} />}
+
+      <View style={[a.flex_1]}>
+        <Text
+          style={[
+            a.text_md,
+            a.font_heavy,
+            a.leading_tight,
+            gtMobile && a.text_lg,
+          ]}
+          numberOfLines={2}>
+          {info.displayName}
+        </Text>
+        <View style={[a.flex_row, {gap: 6}]}>
+          <Text
+            style={[a.flex_shrink, a.text_sm, a.leading_snug]}
+            numberOfLines={1}>
+            {sanitizeHandle(info.creatorHandle, '@')}
+          </Text>
+        </View>
+      </View>
+    </View>
+  )
+}
+
+// TODO: This customization should be a part of the layout component
+export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = useCallback(
+    (evt: GestureResponderEvent) => {
+      onPress?.(evt)
+      if (evt.defaultPrevented) return
+      if (navigation.canGoBack()) {
+        navigation.goBack()
+      } else {
+        navigation.navigate('Home')
+      }
+    },
+    [onPress, navigation],
+  )
+
+  return (
+    <Layout.Header.Slot>
+      <Button
+        label={_(msg`Go back`)}
+        size="small"
+        variant="ghost"
+        color="secondary"
+        shape="round"
+        onPress={onPressBack}
+        hitSlop={HITSLOP_30}
+        style={[
+          {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
+          a.bg_transparent,
+          style,
+        ]}
+        {...props}>
+        <ArrowLeft size="lg" fill="white" />
+      </Button>
+    </Layout.Header.Slot>
+  )
+}
diff --git a/src/screens/VideoFeed/components/Scrubber.tsx b/src/screens/VideoFeed/components/Scrubber.tsx
new file mode 100644
index 000000000..ef3190526
--- /dev/null
+++ b/src/screens/VideoFeed/components/Scrubber.tsx
@@ -0,0 +1,265 @@
+import {useCallback, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {
+  Gesture,
+  GestureDetector,
+  NativeGesture,
+} from 'react-native-gesture-handler'
+import Animated, {
+  interpolate,
+  runOnJS,
+  runOnUI,
+  SharedValue,
+  useAnimatedReaction,
+  useAnimatedStyle,
+  useSharedValue,
+  withTiming,
+} from 'react-native-reanimated'
+import {
+  useSafeAreaFrame,
+  useSafeAreaInsets,
+} from 'react-native-safe-area-context'
+import {useEventListener} from 'expo'
+import {VideoPlayer} from 'expo-video'
+
+import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils'
+import {tokens} from '#/alf'
+import {atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+
+// magic number that is roughly the min height of the write reply button
+// we inset the video by this amount
+export const VIDEO_PLAYER_BOTTOM_INSET = 57
+
+export function Scrubber({
+  active,
+  player,
+  seekingAnimationSV,
+  scrollGesture,
+  children,
+}: {
+  active: boolean
+  player?: VideoPlayer
+  seekingAnimationSV: SharedValue<number>
+  scrollGesture: NativeGesture
+  children?: React.ReactNode
+}) {
+  const {width: screenWidth} = useSafeAreaFrame()
+  const insets = useSafeAreaInsets()
+  const currentTimeSV = useSharedValue(0)
+  const durationSV = useSharedValue(0)
+  const [currentSeekTime, setCurrentSeekTime] = useState(0)
+  const [duration, setDuration] = useState(0)
+
+  const updateTime = (currentTime: number, duration: number) => {
+    'worklet'
+    currentTimeSV.set(currentTime)
+    if (duration !== 0) {
+      durationSV.set(duration)
+    }
+  }
+
+  const isSeekingSV = useSharedValue(false)
+  const seekProgressSV = useSharedValue(0)
+
+  useAnimatedReaction(
+    () => Math.round(seekProgressSV.get()),
+    (progress, prevProgress) => {
+      if (progress !== prevProgress) {
+        runOnJS(setCurrentSeekTime)(progress)
+      }
+    },
+  )
+
+  const seekBy = useCallback(
+    (time: number) => {
+      player?.seekBy(time)
+
+      setTimeout(() => {
+        runOnUI(() => {
+          'worklet'
+          isSeekingSV.set(false)
+          seekingAnimationSV.set(withTiming(0, {duration: 500}))
+        })()
+      }, 50)
+    },
+    [player, isSeekingSV, seekingAnimationSV],
+  )
+
+  const scrubPanGesture = useMemo(() => {
+    return Gesture.Pan()
+      .blocksExternalGesture(scrollGesture)
+      .activeOffsetX([-10, 10])
+      .failOffsetY([-10, 10])
+      .onStart(() => {
+        'worklet'
+        seekProgressSV.set(currentTimeSV.get())
+        isSeekingSV.set(true)
+        seekingAnimationSV.set(withTiming(1, {duration: 500}))
+      })
+      .onUpdate(evt => {
+        'worklet'
+        const progress = evt.x / screenWidth
+        seekProgressSV.set(
+          clamp(progress * durationSV.get(), 0, durationSV.get()),
+        )
+      })
+      .onEnd(evt => {
+        'worklet'
+        isSeekingSV.get()
+
+        const progress = evt.x / screenWidth
+        const newTime = clamp(progress * durationSV.get(), 0, durationSV.get())
+
+        // optimisically set the progress bar
+        seekProgressSV.set(newTime)
+
+        // it's seek by, so offset by the current time
+        // seekBy sets isSeekingSV back to false, so no need to do that here
+        runOnJS(seekBy)(newTime - currentTimeSV.get())
+      })
+  }, [
+    scrollGesture,
+    seekingAnimationSV,
+    seekBy,
+    screenWidth,
+    currentTimeSV,
+    durationSV,
+    isSeekingSV,
+    seekProgressSV,
+  ])
+
+  const timeStyle = useAnimatedStyle(() => {
+    return {
+      display: seekingAnimationSV.get() === 0 ? 'none' : 'flex',
+      opacity: seekingAnimationSV.get(),
+    }
+  })
+
+  const barStyle = useAnimatedStyle(() => {
+    const currentTime = isSeekingSV.get()
+      ? seekProgressSV.get()
+      : currentTimeSV.get()
+    const progress = currentTime === 0 ? 0 : currentTime / durationSV.get()
+    const isSeeking = seekingAnimationSV.get()
+    return {
+      height: isSeeking * 3 + 1,
+      opacity: interpolate(isSeeking, [0, 1], [0.4, 0.6]),
+      width: `${progress * 100}%`,
+    }
+  })
+  const trackStyle = useAnimatedStyle(() => {
+    return {
+      height: seekingAnimationSV.get() * 3 + 1,
+    }
+  })
+  const childrenStyle = useAnimatedStyle(() => {
+    return {
+      opacity: 1 - seekingAnimationSV.get(),
+    }
+  })
+
+  return (
+    <>
+      {player && active && (
+        <PlayerListener
+          player={player}
+          setDuration={setDuration}
+          updateTime={updateTime}
+        />
+      )}
+      <Animated.View
+        style={[
+          a.absolute,
+          {
+            left: 0,
+            right: 0,
+            bottom: insets.bottom + 80,
+          },
+          timeStyle,
+        ]}
+        pointerEvents="none">
+        <Text style={[a.text_center, a.font_bold]}>
+          <Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}>
+            {formatTime(currentSeekTime)}
+          </Text>
+          <Text style={[a.text_2xl, {opacity: 0.8}]}>{'  /  '}</Text>
+          <Text
+            style={[
+              a.text_5xl,
+              {opacity: 0.8},
+              {fontVariant: ['tabular-nums']},
+            ]}>
+            {formatTime(duration)}
+          </Text>
+        </Text>
+      </Animated.View>
+
+      <GestureDetector gesture={scrubPanGesture}>
+        <View
+          style={[
+            a.relative,
+            a.w_full,
+            a.justify_end,
+            {
+              paddingBottom: insets.bottom,
+              minHeight:
+                // bottom padding
+                insets.bottom +
+                // scrubber height
+                tokens.space.lg +
+                // write reply height
+                VIDEO_PLAYER_BOTTOM_INSET,
+            },
+            a.z_10,
+          ]}>
+          <View style={[a.w_full, a.relative]}>
+            <Animated.View
+              style={[
+                a.w_full,
+                {backgroundColor: 'white', opacity: 0.2},
+                trackStyle,
+              ]}
+            />
+            <Animated.View
+              style={[
+                a.absolute,
+                {top: 0, left: 0, backgroundColor: 'white'},
+                barStyle,
+              ]}
+            />
+          </View>
+          <Animated.View
+            style={[{minHeight: VIDEO_PLAYER_BOTTOM_INSET}, childrenStyle]}>
+            {children}
+          </Animated.View>
+        </View>
+      </GestureDetector>
+    </>
+  )
+}
+
+function PlayerListener({
+  player,
+  setDuration,
+  updateTime,
+}: {
+  player: VideoPlayer
+  setDuration: (duration: number) => void
+  updateTime: (currentTime: number, duration: number) => void
+}) {
+  useEventListener(player, 'timeUpdate', evt => {
+    const duration = player.duration
+    if (duration !== 0) {
+      setDuration(Math.round(duration))
+    }
+    runOnUI(updateTime)(evt.currentTime, duration)
+  })
+
+  return null
+}
+
+function clamp(num: number, min: number, max: number) {
+  'worklet'
+  return Math.min(Math.max(num, min), max)
+}
diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx
new file mode 100644
index 000000000..21b2ec5be
--- /dev/null
+++ b/src/screens/VideoFeed/index.tsx
@@ -0,0 +1,1093 @@
+import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
+import {
+  LayoutAnimation,
+  ListRenderItem,
+  Pressable,
+  ScrollView,
+  View,
+  ViewabilityConfig,
+  ViewToken,
+} from 'react-native'
+import {
+  Gesture,
+  GestureDetector,
+  NativeGesture,
+} from 'react-native-gesture-handler'
+import Animated, {
+  useAnimatedStyle,
+  useSharedValue,
+} from 'react-native-reanimated'
+import {
+  useSafeAreaFrame,
+  useSafeAreaInsets,
+} from 'react-native-safe-area-context'
+import {useEventListener} from 'expo'
+import {Image, ImageStyle} from 'expo-image'
+import {LinearGradient} from 'expo-linear-gradient'
+import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video'
+import {
+  AppBskyEmbedVideo,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AtUri,
+  ModerationDecision,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  RouteProp,
+  useFocusEffect,
+  useIsFocused,
+  useNavigation,
+  useRoute,
+} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+
+import {HITSLOP_20} from '#/lib/constants'
+import {useHaptics} from '#/lib/haptics'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {cleanError} from '#/lib/strings/errors'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {isAndroid} from '#/platform/detection'
+import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {
+  FeedFeedbackProvider,
+  useFeedFeedbackContext,
+} from '#/state/feed-feedback'
+import {useFeedFeedback} from '#/state/feed-feedback'
+import {usePostLikeMutationQueue} from '#/state/queries/post'
+import {
+  AuthorFilter,
+  FeedPostSliceItem,
+  usePostFeedQuery,
+} from '#/state/queries/post-feed'
+import {useProfileFollowMutationQueue} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {useComposerControls, useSetMinimalShellMode} from '#/state/shell'
+import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
+import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
+import {List} from '#/view/com/util/List'
+import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {Header} from '#/screens/VideoFeed/components/Header'
+import {atoms as a, platform, ThemeProvider, useTheme} from '#/alf'
+import {setNavigationBar} from '#/alf/util/navigationBar'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
+import {Leaf_Stroke2_Corner0_Rounded as LeafIcon} from '#/components/icons/Leaf'
+import * as Layout from '#/components/Layout'
+import {Link} from '#/components/Link'
+import {ListFooter} from '#/components/Lists'
+import * as Hider from '#/components/moderation/Hider'
+import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
+import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber'
+
+function createThreeVideoPlayers(
+  sources?: [string, string, string],
+): [VideoPlayer, VideoPlayer, VideoPlayer] {
+  // android is typically slower and can't keep up with a 0.1 interval
+  const eventInterval = platform({
+    ios: 0.2,
+    android: 0.5,
+    default: 0,
+  })
+  const p1 = createVideoPlayer(sources?.[0] ?? '')
+  p1.loop = true
+  p1.timeUpdateEventInterval = eventInterval
+  const p2 = createVideoPlayer(sources?.[1] ?? '')
+  p2.loop = true
+  p2.timeUpdateEventInterval = eventInterval
+  const p3 = createVideoPlayer(sources?.[2] ?? '')
+  p3.loop = true
+  p3.timeUpdateEventInterval = eventInterval
+  return [p1, p2, p3]
+}
+
+export function VideoFeed({}: NativeStackScreenProps<
+  CommonNavigatorParams,
+  'VideoFeed'
+>) {
+  const {top} = useSafeAreaInsets()
+  const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
+
+  const t = useTheme()
+  const setMinShellMode = useSetMinimalShellMode()
+  useFocusEffect(
+    useCallback(() => {
+      setMinShellMode(true)
+      setNavigationBar('lightbox', t)
+      return () => {
+        setMinShellMode(false)
+        setNavigationBar('theme', t)
+      }
+    }, [setMinShellMode, t]),
+  )
+
+  const isFocused = useIsFocused()
+  useSetLightStatusBar(isFocused)
+
+  return (
+    <ThemeProvider theme="dark">
+      <Layout.Screen noInsetTop style={{backgroundColor: 'black'}}>
+        <View
+          style={[
+            a.absolute,
+            a.z_30,
+            {top: 0, left: 0, right: 0, paddingTop: top},
+          ]}>
+          <Header sourceContext={params} />
+        </View>
+        <Feed />
+      </Layout.Screen>
+    </ThemeProvider>
+  )
+}
+
+const viewabilityConfig = {
+  itemVisiblePercentThreshold: 100,
+  minimumViewTime: 0,
+} satisfies ViewabilityConfig
+
+type CurrentSource = {
+  source: string
+} | null
+
+type VideoItem = {
+  moderation: ModerationDecision
+  post: AppBskyFeedDefs.PostView
+  feedContext: string | undefined
+}
+
+function Feed() {
+  const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
+  const isFocused = useIsFocused()
+  const {hasSession} = useSession()
+  const {height} = useSafeAreaFrame()
+
+  const feedDesc = useMemo(() => {
+    switch (params.type) {
+      case 'feedgen':
+        return `feedgen|${params.uri as string}` as const
+      case 'author':
+        return `author|${params.did as string}|${
+          params.filter as AuthorFilter
+        }` as const
+      default:
+        throw new Error(`Invalid video feed params ${JSON.stringify(params)}`)
+    }
+  }, [params])
+  const feedFeedback = useFeedFeedback(feedDesc, hasSession)
+  const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} =
+    usePostFeedQuery(
+      feedDesc,
+      params.type === 'feedgen' && params.sourceInterstitial !== 'none'
+        ? {feedCacheKey: params.sourceInterstitial}
+        : undefined,
+    )
+
+  const videos = useMemo(() => {
+    let vids =
+      data?.pages
+        .flatMap(page => {
+          const items: {
+            _reactKey: string
+            moderation: ModerationDecision
+            post: AppBskyFeedDefs.PostView
+            feedContext: string | undefined
+          }[] = []
+          for (const slice of page.slices) {
+            for (const i of slice.items) {
+              items.push({
+                _reactKey: i._reactKey,
+                moderation: i.moderation,
+                post: i.post,
+                feedContext: slice.feedContext,
+              })
+            }
+          }
+          return items
+        })
+        .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) || []
+    const startingVideoIndex = vids?.findIndex(video => {
+      return video.post.uri === params.initialPostUri
+    })
+    if (vids && startingVideoIndex && startingVideoIndex > -1) {
+      vids = vids.slice(startingVideoIndex)
+    }
+    return vids
+  }, [data, params.initialPostUri])
+
+  const [currentSources, setCurrentSources] = useState<
+    [CurrentSource, CurrentSource, CurrentSource]
+  >([null, null, null])
+
+  const [players, setPlayers] = useState<
+    [VideoPlayer, VideoPlayer, VideoPlayer] | null
+  >(null)
+
+  const [currentIndex, setCurrentIndex] = useState(0)
+
+  const scrollGesture = useMemo(() => Gesture.Native(), [])
+
+  const renderItem: ListRenderItem<VideoItem> = useCallback(
+    ({item, index}) => {
+      const {post} = item
+
+      // filtered above, here for TS
+      if (!post.embed || !AppBskyEmbedVideo.isView(post.embed)) {
+        return null
+      }
+
+      const player = players?.[index % 3]
+      const currentSource = currentSources[index % 3]
+
+      return (
+        <VideoItem
+          player={player}
+          post={post}
+          embed={post.embed}
+          active={
+            isFocused &&
+            index === currentIndex &&
+            currentSource?.source === post.embed.playlist
+          }
+          moderation={item.moderation}
+          scrollGesture={scrollGesture}
+          feedContext={item.feedContext}
+        />
+      )
+    },
+    [players, currentIndex, isFocused, currentSources, scrollGesture],
+  )
+
+  const updateVideoState = useCallback(
+    (index?: number) => {
+      if (!videos.length) return
+
+      if (index === undefined) {
+        index = currentIndex
+      } else {
+        setCurrentIndex(index)
+      }
+
+      const prevSlice = videos.at(index - 1)
+      const prevPost = prevSlice?.post
+      const prevEmbed = prevPost?.embed
+      const prevVideo =
+        prevEmbed && AppBskyEmbedVideo.isView(prevEmbed)
+          ? prevEmbed.playlist
+          : null
+      const currSlice = videos.at(index)
+      const currPost = currSlice?.post
+      const currEmbed = currPost?.embed
+      const currVideo =
+        currEmbed && AppBskyEmbedVideo.isView(currEmbed)
+          ? currEmbed.playlist
+          : null
+      const currVideoModeration = currSlice?.moderation
+      const nextSlice = videos.at(index + 1)
+      const nextPost = nextSlice?.post
+      const nextEmbed = nextPost?.embed
+      const nextVideo =
+        nextEmbed && AppBskyEmbedVideo.isView(nextEmbed)
+          ? nextEmbed.playlist
+          : null
+
+      const prevPlayerCurrentSource = currentSources[(index + 2) % 3]
+      const currPlayerCurrentSource = currentSources[index % 3]
+      const nextPlayerCurrentSource = currentSources[(index + 1) % 3]
+
+      if (!players) {
+        const args = ['', '', ''] satisfies [string, string, string]
+        if (prevVideo) args[(index + 2) % 3] = prevVideo
+        if (currVideo) args[index % 3] = currVideo
+        if (nextVideo) args[(index + 1) % 3] = nextVideo
+        const [player1, player2, player3] = createThreeVideoPlayers(args)
+
+        setPlayers([player1, player2, player3])
+
+        if (currVideo) {
+          const currPlayer = [player1, player2, player3][index % 3]
+          currPlayer.play()
+        }
+      } else {
+        const [player1, player2, player3] = players
+
+        const prevPlayer = [player1, player2, player3][(index + 2) % 3]
+        const currPlayer = [player1, player2, player3][index % 3]
+        const nextPlayer = [player1, player2, player3][(index + 1) % 3]
+
+        if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) {
+          prevPlayer.replace(prevVideo)
+        }
+        prevPlayer.pause()
+
+        if (currVideo) {
+          if (currVideo !== currPlayerCurrentSource?.source) {
+            currPlayer.replace(currVideo)
+          }
+          if (
+            currVideoModeration &&
+            (currVideoModeration.ui('contentView').blur ||
+              currVideoModeration.ui('contentMedia').blur)
+          ) {
+            currPlayer.pause()
+          } else {
+            currPlayer.play()
+          }
+        }
+
+        if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) {
+          nextPlayer.replace(nextVideo)
+        }
+        nextPlayer.pause()
+      }
+
+      const updatedSources: [CurrentSource, CurrentSource, CurrentSource] = [
+        ...currentSources,
+      ]
+      if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) {
+        updatedSources[(index + 2) % 3] = {
+          source: prevVideo,
+        }
+      }
+      if (currVideo && currVideo !== currPlayerCurrentSource?.source) {
+        updatedSources[index % 3] = {
+          source: currVideo,
+        }
+      }
+      if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) {
+        updatedSources[(index + 1) % 3] = {
+          source: nextVideo,
+        }
+      }
+
+      if (
+        updatedSources[0]?.source !== currentSources[0]?.source ||
+        updatedSources[1]?.source !== currentSources[1]?.source ||
+        updatedSources[2]?.source !== currentSources[2]?.source
+      ) {
+        setCurrentSources(updatedSources)
+      }
+    },
+    [videos, currentSources, currentIndex, players],
+  )
+
+  const updateVideoStateInitially = useNonReactiveCallback(() => {
+    updateVideoState()
+  })
+
+  useFocusEffect(
+    useCallback(() => {
+      if (!players) {
+        // create players, set sources, start playing
+        updateVideoStateInitially()
+      }
+      return () => {
+        if (players) {
+          // manually release players when offscreen
+          players.forEach(p => p.release())
+          setPlayers(null)
+        }
+      }
+    }, [players, updateVideoStateInitially]),
+  )
+
+  const onViewableItemsChanged = useCallback(
+    ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
+      if (viewableItems[0] && viewableItems[0].index !== null) {
+        updateVideoState(viewableItems[0].index)
+      }
+    },
+    [updateVideoState],
+  )
+
+  const renderEndMessage = useCallback(() => <EndMessage />, [])
+
+  return (
+    <FeedFeedbackProvider value={feedFeedback}>
+      <GestureDetector gesture={scrollGesture}>
+        <List
+          data={videos}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          initialNumToRender={3}
+          maxToRenderPerBatch={3}
+          windowSize={6}
+          pagingEnabled={true}
+          ListFooterComponent={
+            <ListFooter
+              hasNextPage={hasNextPage}
+              isFetchingNextPage={isFetchingNextPage}
+              error={cleanError(error)}
+              onRetry={fetchNextPage}
+              height={height}
+              showEndMessage
+              renderEndMessage={renderEndMessage}
+              style={[a.justify_center, a.border_0]}
+            />
+          }
+          onEndReached={() => {
+            if (hasNextPage && !isFetchingNextPage) {
+              fetchNextPage()
+            }
+          }}
+          showsVerticalScrollIndicator={false}
+          onViewableItemsChanged={onViewableItemsChanged}
+          viewabilityConfig={viewabilityConfig}
+        />
+      </GestureDetector>
+    </FeedFeedbackProvider>
+  )
+}
+
+function keyExtractor(item: FeedPostSliceItem) {
+  return item._reactKey
+}
+
+let VideoItem = ({
+  player,
+  post,
+  embed,
+  active,
+  scrollGesture,
+  moderation,
+  feedContext,
+}: {
+  player?: VideoPlayer
+  post: AppBskyFeedDefs.PostView
+  embed: AppBskyEmbedVideo.View
+  active: boolean
+  scrollGesture: NativeGesture
+  moderation?: ModerationDecision
+  feedContext: string | undefined
+}): React.ReactNode => {
+  const postShadow = usePostShadow(post)
+  const {width, height} = useSafeAreaFrame()
+  const {sendInteraction} = useFeedFeedbackContext()
+
+  useEffect(() => {
+    if (active) {
+      sendInteraction({
+        item: post.uri,
+        event: 'app.bsky.feed.defs#interactionSeen',
+        feedContext,
+      })
+    }
+  }, [active, post.uri, feedContext, sendInteraction])
+
+  return (
+    <View style={[a.relative, {height, width}]}>
+      {postShadow === POST_TOMBSTONE ? (
+        <View
+          style={[
+            a.absolute,
+            a.inset_0,
+            a.z_20,
+            a.align_center,
+            a.justify_center,
+            {backgroundColor: 'rgba(0, 0, 0, 0.8)'},
+          ]}>
+          <Text
+            style={[
+              a.text_2xl,
+              a.font_heavy,
+              a.text_center,
+              a.leading_tight,
+              a.mx_xl,
+            ]}>
+            <Trans>Post has been deleted</Trans>
+          </Text>
+        </View>
+      ) : (
+        <>
+          <VideoItemPlaceholder embed={embed} />
+          {active && player && <VideoItemInner player={player} embed={embed} />}
+          {moderation && (
+            <Overlay
+              player={player}
+              post={postShadow}
+              embed={embed}
+              active={active}
+              scrollGesture={scrollGesture}
+              moderation={moderation}
+              feedContext={feedContext}
+            />
+          )}
+        </>
+      )}
+    </View>
+  )
+}
+VideoItem = memo(VideoItem)
+
+function VideoItemInner({
+  player,
+  embed,
+}: {
+  player: VideoPlayer
+  embed: AppBskyEmbedVideo.View
+}) {
+  const {bottom} = useSafeAreaInsets()
+  const [isReady, setIsReady] = useState(!isAndroid)
+
+  useEventListener(player, 'timeUpdate', evt => {
+    if (isAndroid && !isReady && evt.currentTime >= 0.05) {
+      setIsReady(true)
+    }
+  })
+
+  return (
+    <VideoView
+      accessible={false}
+      style={[
+        a.absolute,
+        {
+          top: 0,
+          left: 0,
+          right: 0,
+          bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET,
+        },
+        !isReady && {opacity: 0},
+      ]}
+      player={player}
+      nativeControls={false}
+      contentFit={isTallAspectRatio(embed.aspectRatio) ? 'cover' : 'contain'}
+      accessibilityIgnoresInvertColors
+    />
+  )
+}
+
+function ModerationOverlay({
+  embed,
+  onPressShow,
+}: {
+  embed: AppBskyEmbedVideo.View
+  onPressShow: () => void
+}) {
+  const {_} = useLingui()
+  const hider = Hider.useHider()
+  const {bottom} = useSafeAreaInsets()
+
+  const onShow = useCallback(() => {
+    hider.setIsContentVisible(true)
+    onPressShow()
+  }, [hider, onPressShow])
+
+  return (
+    <View style={[a.absolute, a.inset_0, a.z_20]}>
+      <VideoItemPlaceholder blur embed={embed} />
+      <View
+        style={[
+          a.absolute,
+          a.inset_0,
+          a.z_20,
+          a.justify_center,
+          a.align_center,
+          {backgroundColor: 'rgba(0, 0, 0, 0.8)'},
+        ]}>
+        <View style={[a.align_center, a.gap_sm]}>
+          <Eye width={36} fill="white" />
+          <Text style={[a.text_center, a.leading_snug, a.pb_xs]}>
+            <Trans>Hidden by your moderation settings.</Trans>
+          </Text>
+          <Button
+            label={_(msg`Show anyway`)}
+            size="small"
+            variant="solid"
+            color="secondary_inverted"
+            onPress={onShow}>
+            <ButtonText>
+              <Trans>Show anyway</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+        <View
+          style={[
+            a.absolute,
+            a.inset_0,
+            a.px_xl,
+            a.pt_4xl,
+            {
+              top: 'auto',
+              paddingBottom: bottom,
+            },
+          ]}>
+          <LinearGradient
+            colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.4)']}
+            style={[a.absolute, a.inset_0]}
+          />
+          <Divider style={{borderColor: 'white'}} />
+          <View>
+            <Button
+              label={_(msg`View details`)}
+              onPress={() => {
+                hider.showInfoDialog()
+              }}
+              style={[
+                a.w_full,
+                {
+                  height: 60,
+                },
+              ]}>
+              {({pressed}) => (
+                <Text
+                  style={[
+                    a.text_sm,
+                    a.font_bold,
+                    a.text_center,
+                    {opacity: pressed ? 0.5 : 1},
+                  ]}>
+                  <Trans>View details</Trans>
+                </Text>
+              )}
+            </Button>
+          </View>
+        </View>
+      </View>
+    </View>
+  )
+}
+
+function Overlay({
+  player,
+  post,
+  embed,
+  active,
+  scrollGesture,
+  moderation,
+  feedContext,
+}: {
+  player?: VideoPlayer
+  post: Shadow<AppBskyFeedDefs.PostView>
+  embed: AppBskyEmbedVideo.View
+  active: boolean
+  scrollGesture: NativeGesture
+  moderation: ModerationDecision
+  feedContext: string | undefined
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {openComposer} = useComposerControls()
+  const navigation = useNavigation<NavigationProp>()
+  const seekingAnimationSV = useSharedValue(0)
+
+  const profile = useProfileShadow(post.author)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
+    profile,
+    'ImmersiveVideo',
+  )
+
+  const rkey = new AtUri(post.uri).rkey
+  const record = AppBskyFeedPost.isRecord(post.record) ? post.record : undefined
+  const richText = new RichTextAPI({
+    text: record?.text || '',
+    facets: record?.facets,
+  })
+
+  const animatedStyle = useAnimatedStyle(() => ({
+    opacity: 1 - seekingAnimationSV.get(),
+  }))
+
+  const onPressShow = useCallback(() => {
+    player?.play()
+  }, [player])
+
+  const mergedModui = useMemo(() => {
+    const modui = moderation.ui('contentView')
+    const mediaModui = moderation.ui('contentMedia')
+    modui.alerts = [...modui.alerts, ...mediaModui.alerts]
+    modui.blurs = [...modui.blurs, ...mediaModui.blurs]
+    modui.filters = [...modui.filters, ...mediaModui.filters]
+    modui.informs = [...modui.informs, ...mediaModui.informs]
+    return modui
+  }, [moderation])
+
+  const onPressReply = useCallback(() => {
+    openComposer({
+      replyTo: {
+        uri: post.uri,
+        cid: post.cid,
+        text: record?.text || '',
+        author: post.author,
+        embed: post.embed,
+      },
+    })
+  }, [openComposer, post, record])
+
+  return (
+    <Hider.Outer modui={mergedModui}>
+      <Hider.Mask>
+        <ModerationOverlay embed={embed} onPressShow={onPressShow} />
+      </Hider.Mask>
+      <Hider.Content>
+        <View style={[a.absolute, a.inset_0, a.z_20]}>
+          <View style={[a.flex_1]}>
+            <PlayPauseTapArea
+              player={player}
+              post={post}
+              feedContext={feedContext}
+            />
+          </View>
+
+          <LinearGradient
+            colors={[
+              'rgba(0,0,0,0)',
+              'rgba(0,0,0,0.7)',
+              'rgba(0,0,0,0.95)',
+              'rgba(0,0,0,0.95)',
+            ]}
+            style={[a.w_full, a.pt_md]}>
+            <Animated.View style={[a.px_xl, animatedStyle]}>
+              <View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}>
+                <Link
+                  label={_(
+                    msg`View ${sanitizeDisplayName(
+                      post.author.displayName || post.author.handle,
+                    )}'s profile`,
+                  )}
+                  to={{
+                    screen: 'Profile',
+                    params: {name: post.author.did},
+                  }}
+                  style={[a.flex_1, a.flex_row, a.gap_md, a.align_center]}>
+                  <UserAvatar
+                    type="user"
+                    avatar={post.author.avatar}
+                    size={32}
+                  />
+                  <View style={[a.flex_1]}>
+                    <Text
+                      style={[a.text_md, a.font_heavy]}
+                      emoji
+                      numberOfLines={1}>
+                      {sanitizeDisplayName(
+                        post.author.displayName || post.author.handle,
+                      )}
+                    </Text>
+                    <Text
+                      style={[a.text_sm, t.atoms.text_contrast_high]}
+                      numberOfLines={1}>
+                      {sanitizeHandle(post.author.handle, '@')}
+                    </Text>
+                  </View>
+                </Link>
+                {/* show button based on non-reactive version, so it doesn't hide on press */}
+                {!post.author.viewer?.following && (
+                  <Button
+                    label={
+                      profile.viewer?.following
+                        ? _(msg`Following`)
+                        : _(msg`Follow`)
+                    }
+                    accessibilityHint={
+                      profile.viewer?.following ? _(msg`Unfollow user`) : ''
+                    }
+                    size="small"
+                    variant="solid"
+                    color="secondary_inverted"
+                    style={[a.mb_xs]}
+                    onPress={() =>
+                      profile.viewer?.following
+                        ? queueUnfollow()
+                        : queueFollow()
+                    }>
+                    {!!profile.viewer?.following && (
+                      <ButtonIcon icon={CheckIcon} />
+                    )}
+                    <ButtonText>
+                      {profile.viewer?.following ? (
+                        <Trans>Following</Trans>
+                      ) : (
+                        <Trans>Follow</Trans>
+                      )}
+                    </ButtonText>
+                  </Button>
+                )}
+              </View>
+              {record?.text?.trim() && (
+                <ExpandableRichTextView
+                  value={richText}
+                  authorHandle={post.author.handle}
+                />
+              )}
+              {record && (
+                <View style={[{left: -5}]}>
+                  <PostCtrls
+                    richText={richText}
+                    post={post}
+                    record={record}
+                    logContext="FeedItem"
+                    onPressReply={() =>
+                      navigation.navigate('PostThread', {
+                        name: post.author.did,
+                        rkey,
+                      })
+                    }
+                    big
+                  />
+                </View>
+              )}
+            </Animated.View>
+            <Scrubber
+              active={active}
+              player={player}
+              seekingAnimationSV={seekingAnimationSV}
+              scrollGesture={scrollGesture}>
+              <PostThreadComposePrompt onPressCompose={onPressReply} />
+            </Scrubber>
+          </LinearGradient>
+        </View>
+        {/*
+        {isAndroid && status === 'loading' && (
+          <View
+            style={[
+              a.absolute,
+              a.inset_0,
+              a.align_center,
+              a.justify_center,
+              a.z_10,
+            ]}
+            pointerEvents="none">
+            <Loader size="2xl" />
+          </View>
+        )}
+          */}
+      </Hider.Content>
+    </Hider.Outer>
+  )
+}
+
+function ExpandableRichTextView({
+  value,
+  authorHandle,
+}: {
+  value: RichTextAPI
+  authorHandle?: string
+}) {
+  const {height: screenHeight} = useSafeAreaFrame()
+  const [expanded, setExpanded] = useState(false)
+  const [hasBeenExpanded, setHasBeenExpanded] = useState(false)
+  const [constrained, setConstrained] = useState(false)
+  const [contentHeight, setContentHeight] = useState(0)
+  const {_} = useLingui()
+
+  if (expanded && !hasBeenExpanded) {
+    setHasBeenExpanded(true)
+  }
+
+  return (
+    <ScrollView
+      scrollEnabled={expanded}
+      onContentSizeChange={(_w, h) => {
+        if (hasBeenExpanded) {
+          LayoutAnimation.configureNext({
+            duration: 500,
+            update: {type: 'spring', springDamping: 0.6},
+          })
+        }
+        setContentHeight(h)
+      }}
+      style={{height: Math.min(contentHeight, screenHeight * 0.5)}}
+      contentContainerStyle={[
+        a.py_sm,
+        a.gap_xs,
+        expanded ? [a.align_start] : a.flex_row,
+      ]}>
+      <RichText
+        value={value}
+        style={[a.text_sm, a.flex_1, a.leading_normal]}
+        authorHandle={authorHandle}
+        enableTags
+        numberOfLines={expanded ? undefined : constrained ? 2 : 2}
+        onTextLayout={evt => {
+          if (!constrained && evt.nativeEvent.lines.length > 1) {
+            setConstrained(true)
+          }
+        }}
+      />
+      {constrained && (
+        <Pressable
+          accessibilityHint={_(msg`Tap to expand or collapse post text.`)}
+          accessibilityLabel={expanded ? _(msg`Read less`) : _(msg`Read more`)}
+          hitSlop={HITSLOP_20}
+          onPress={() => setExpanded(prev => !prev)}
+          style={[a.absolute, a.inset_0]}
+        />
+      )}
+    </ScrollView>
+  )
+}
+
+function VideoItemPlaceholder({
+  embed,
+  style,
+  blur,
+}: {
+  embed: AppBskyEmbedVideo.View
+  style?: ImageStyle
+  blur?: boolean
+}) {
+  const {bottom} = useSafeAreaInsets()
+  const src = embed.thumbnail
+  let contentFit = isTallAspectRatio(embed.aspectRatio)
+    ? ('cover' as const)
+    : ('contain' as const)
+  if (blur) {
+    contentFit = 'cover' as const
+  }
+  return src ? (
+    <Image
+      accessibilityIgnoresInvertColors
+      source={{uri: src}}
+      style={[
+        a.absolute,
+        blur
+          ? a.inset_0
+          : {
+              top: 0,
+              left: 0,
+              right: 0,
+              bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET,
+            },
+        style,
+      ]}
+      contentFit={contentFit}
+      blurRadius={blur ? 100 : 0}
+    />
+  ) : null
+}
+
+function PlayPauseTapArea({
+  player,
+  post,
+  feedContext,
+}: {
+  player?: VideoPlayer
+  post: Shadow<AppBskyFeedDefs.PostView>
+  feedContext: string | undefined
+}) {
+  const {_} = useLingui()
+  const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const playHaptic = useHaptics()
+  const [queueLike] = usePostLikeMutationQueue(post, 'ImmersiveVideo')
+  const {sendInteraction} = useFeedFeedbackContext()
+
+  const togglePlayPause = () => {
+    if (!player) return
+    doubleTapRef.current = null
+    if (player.playing) {
+      player.pause()
+    } else {
+      player.play()
+    }
+  }
+
+  const onPress = () => {
+    if (doubleTapRef.current) {
+      clearTimeout(doubleTapRef.current)
+      doubleTapRef.current = null
+      playHaptic('Light')
+      queueLike()
+      sendInteraction({
+        item: post.uri,
+        event: 'app.bsky.feed.defs#interactionLike',
+        feedContext,
+      })
+    } else {
+      doubleTapRef.current = setTimeout(togglePlayPause, 200)
+    }
+  }
+
+  return (
+    <Button
+      disabled={!player}
+      label={_(`Tap to play or pause the video`)}
+      accessibilityHint={_(msg`Double tap to like`)}
+      onPress={onPress}
+      style={[a.absolute, a.inset_0]}>
+      <View />
+    </Button>
+  )
+}
+
+function EndMessage() {
+  const navigation = useNavigation<NavigationProp>()
+  const {_} = useLingui()
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.gap_3xl,
+        a.px_lg,
+        a.mx_auto,
+        a.align_center,
+        {maxWidth: 350},
+      ]}>
+      <View
+        style={[
+          {height: 100, width: 100},
+          a.rounded_full,
+          t.atoms.bg_contrast_700,
+          a.align_center,
+          a.justify_center,
+        ]}>
+        <LeafIcon width={64} fill="black" />
+      </View>
+      <View style={[a.w_full, a.gap_md]}>
+        <Text style={[a.text_3xl, a.text_center, a.font_heavy]}>
+          <Trans>That's everything!</Trans>
+        </Text>
+        <Text
+          style={[
+            a.text_lg,
+            a.text_center,
+            t.atoms.text_contrast_high,
+            a.leading_snug,
+          ]}>
+          <Trans>
+            You've run out of videos to watch. Maybe it's a good time to take a
+            break?
+          </Trans>
+        </Text>
+      </View>
+      <Button
+        testID="videoFeedGoBackButton"
+        onPress={() => {
+          if (navigation.canGoBack()) {
+            navigation.goBack()
+          } else {
+            navigation.navigate('Home')
+          }
+        }}
+        variant="solid"
+        color="secondary_inverted"
+        size="small"
+        label={_(msg`Go back`)}
+        accessibilityHint={_(msg`Returns to previous page`)}>
+        <ButtonIcon icon={ArrowLeftIcon} />
+        <ButtonText>
+          <Trans>Go back</Trans>
+        </ButtonText>
+      </Button>
+    </View>
+  )
+}
+
+/*
+ * If the video is taller than 9:16
+ */
+function isTallAspectRatio(aspectRatio: AppBskyEmbedVideo.View['aspectRatio']) {
+  const videoAspectRatio =
+    (aspectRatio?.width ?? 1) / (aspectRatio?.height ?? 1)
+  return videoAspectRatio <= 9 / 16
+}
diff --git a/src/screens/VideoFeed/index.web.tsx b/src/screens/VideoFeed/index.web.tsx
new file mode 100644
index 000000000..38ec8cc0a
--- /dev/null
+++ b/src/screens/VideoFeed/index.web.tsx
@@ -0,0 +1,3 @@
+export function VideoScreen() {
+  return null
+}
diff --git a/src/screens/VideoFeed/types.ts b/src/screens/VideoFeed/types.ts
new file mode 100644
index 000000000..2ab854bb3
--- /dev/null
+++ b/src/screens/VideoFeed/types.ts
@@ -0,0 +1,18 @@
+import {AuthorFilter} from '#/state/queries/post-feed'
+
+/**
+ * Kind of like `FeedDescriptor` but not
+ */
+export type VideoFeedSourceContext =
+  | {
+      type: 'feedgen'
+      uri: string
+      sourceInterstitial: 'discover' | 'explore' | 'none'
+      initialPostUri?: string
+    }
+  | {
+      type: 'author'
+      did: string
+      filter: AuthorFilter
+      initialPostUri?: string
+    }