about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2025-01-19 17:17:41 -0800
committerGitHub <noreply@github.com>2025-01-19 17:17:41 -0800
commit34582edf3ea17789684100172d6dd496220482b0 (patch)
treeca29a927bf015107a60a867d8266274c8a78de49
parentcb020655504dd0d39f8e91fd517f14dc4a82c307 (diff)
downloadvoidsky-34582edf3ea17789684100172d6dd496220482b0.tar.zst
yolo (#7499)
* tweaks to constants (#7478)

* add did

* use correct did

* typo

* tweak

* Prevent Drawer gesture conflicting with Suggestions scroll (#7468)

* Extract BlockDrawerGeesture

* Block drawer when scrolling interstitials

(cherry picked from commit 9e3f2f43745eed9c71cb985e48135b7363d91aa9)

* yolo interstitial

* yolo mode

* right swipe

* fix nav gesture

* vibe controls

* collapsible post text

* rm blurview, cover for tall videos

* smarter video source handling

* use thumbnails, improve perf significantly

* better android loading

* improve aspect ratio

* optimize source changes

* rm spinner on ios

* whoops, remove debug `false`

* FIX WRONG VIDEOS SHOWING UP

* don't spring on way down

* release video players when leaving screen

* remove jank animation

* Add grid

* improve contract, fix double tap

* Filter out posts without videos

* Only do grid on native

* Pipe through feedSourceUri and link to feed

* Handle passed through params

* Partial revert, just filter posts to start at index

* Clean up cards, remove entry interstitial

* Tweak handle

* Change constant name

* Rename some things

* Make types legit

* Clean up more naming

* Add placeholder for grid view

* Handle web, set up new organization

* Begin work on Header

* Replace types

* Squashed commit of the following:

commit 3d1be4c0f19789dd3c5a3572ec1acd744a2edb80
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 01:08:05 2025 +0000

    extend animation

commit c9f199413b018efcbd9d8d2a58dd05eb41e7acb7
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 01:01:24 2025 +0000

    fix gap

commit 22e520795f50efda176f21a5e967cb27d0cdd907
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:50:16 2025 +0000

    thinner bar, format time

commit c32427f21405294ed3567545629a2964c4af59fe
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:47:57 2025 +0000

    fix 2 in 3 screens

commit cbf84c08d64ca0a08ba9070ef5db918f89aa4296
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:45:46 2025 +0000

    rm unneeded var

commit 7e0e100177bb1cd0e64c0841bb7685c7f1eb857f
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:41:18 2025 +0000

    scrubberrrrr

* use white with opacity rather than gray

* Simultaneous gesture

* cleanup attempt

* fix jank

* link to profile on press

* fix jitter fr this time

* mostly fix android flicker

* Maybe fix row generation

* Add content hider to video card

* emoji in post text

* reduce update rate

* fix type error

* Fix grid layout trailing single item

* Add Discover interstitial, settings, includes pin for now

* Explore interstitial, handle dimissal, pinning, compact card

* Only use grid placeholder on native

* Update events

* Add feature gate

* android nav bar fixes + lower update speed

* fix interval + decel rate on interstitials

* attempt to fix broken scrub on android (not working)

* follow button

* Part out the interstitials for perf, add view more

* Remove prod web route

* Wrap interstitials with BlockDrawerGesture

* Bring video cropping in line with images (#7462)

* Mimic image cropping for videos on web

* Same on native

* Rename variables for clarity

* Fix Android scrubbing

* Add FeedFeedbackProvider

* Remove swipe gesture

* fix light status bar behaviour

* bump

* feedback

* Copy pasta to new location

* Copy pasta part deux

* Filter only videos

* Make whole text clickable to expand

(cherry picked from commit 4cf31120779f4e06eb4c296b3d4b53814d432b07)

* move scrubber to own file

* end card

* add icon to end card

* add min view time to viewability config

* play haptic on like

* tweak feedback

* tweak feedback again

* Moderation

(cherry picked from commit 6b6b471cfb363031284b3e7a1f6e0ade3ac4ae47)

* remove bad check

* fix feedback for new video grid

* change prop name to items as well

* Simplify logic

* Fix mod footer

* Give scrubber more space on android

* Add subtle track behind scrubber, adjust opacity

* wire in feed context again...

* Add better a11y desc to card

* Fix key issue

* Update a11y copy

* Fix scrubber height

* improve scrubber animation

* Make follow button more obvious

* Make header back button more clear

* Disable interactions with actual video el

* keep content away from the bottom safe area

* fix blur

* fix moderation issue

* improve contrast on mod screen

* Make moderation static per item

* Memoize rows

* Optimizations

* Take video moderation into account

* Only blur titles for list blur

* Change copy

* Bump blur radius

* animate text in both directions

* Rm unused field

* Filter by root early

* Refactor for clarity

* add compose prompt to scrubber

* rm log

* tweak gradient

* Bump SDK, use contentMode to power video feed

* Ensure ProfileFeed view also supports video feed

* improve scrubber on android

* rm border from footer

* Update prod video feed did

* Separate caches

* Add lil hover to View More

* Fix undefined logic, remove header for interstitial

* Ungate

* Fix stuckness

* remove extra useless map

* Fix effect cleanup

* Send seen without cleanup

* Simplify react stuff

* Earlier early return to avoid loading flash

* remove scrubber placeholder

* Remove opacity hack

* Render useEvent conditionally

* Fix Android flash

---------

Co-authored-by: dan <dan.abramov@gmail.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
-rw-r--r--package.json4
-rw-r--r--src/Navigation.tsx9
-rw-r--r--src/alf/themes.ts2
-rw-r--r--src/components/Dialog/index.tsx4
-rw-r--r--src/components/Grid.tsx59
-rw-r--r--src/components/Layout/Header/index.tsx6
-rw-r--r--src/components/LinearGradientBackground.tsx14
-rw-r--r--src/components/Lists.tsx12
-rw-r--r--src/components/RichText.tsx10
-rw-r--r--src/components/VideoPostCard.tsx540
-rw-r--r--src/components/feeds/PostFeedVideoGridRow.tsx67
-rw-r--r--src/components/interstitials/TrendingVideos.tsx231
-rw-r--r--src/lib/constants.ts5
-rw-r--r--src/lib/routes/links.ts8
-rw-r--r--src/lib/routes/types.ts9
-rw-r--r--src/lib/statsig/events.ts19
-rw-r--r--src/routes.ts1
-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
-rw-r--r--src/state/feed-feedback.tsx11
-rw-r--r--src/state/persisted/schema.ts2
-rw-r--r--src/state/preferences/trending.tsx20
-rw-r--r--src/state/queries/feed.ts6
-rw-r--r--src/state/queries/post-feed.ts3
-rw-r--r--src/view/com/feeds/FeedPage.tsx14
-rw-r--r--src/view/com/post-thread/PostThreadComposePrompt.tsx1
-rw-r--r--src/view/com/posts/PostFeed.tsx271
-rw-r--r--src/view/com/util/List.tsx6
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx2
-rw-r--r--src/view/screens/Home.tsx2
-rw-r--r--src/view/screens/Search/Explore.tsx17
-rw-r--r--yarn.lock43
38 files changed, 3164 insertions, 125 deletions
diff --git a/package.json b/package.json
index 8fe42108d..1133898ff 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "bsky.app",
-  "version": "1.96.5",
+  "version": "1.96.6",
   "private": true,
   "engines": {
     "node": ">=20"
@@ -54,7 +54,7 @@
     "icons:optimize": "svgo -f ./assets/icons"
   },
   "dependencies": {
-    "@atproto/api": "^0.13.21",
+    "@atproto/api": "^0.13.28",
     "@braintree/sanitize-url": "^6.0.2",
     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
     "@emoji-mart/react": "^1.1.1",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 18705c5ff..a6332c5d8 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -86,6 +86,7 @@ import {
   StarterPackScreenShort,
 } from '#/screens/StarterPack/StarterPackScreen'
 import {Wizard} from '#/screens/StarterPack/Wizard'
+import {VideoFeed} from '#/screens/VideoFeed'
 import {useTheme} from '#/alf'
 import {router} from '#/routes'
 import {Referrer} from '../modules/expo-bluesky-swiss-army'
@@ -422,6 +423,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         getComponent={() => Wizard}
         options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
       />
+      <Stack.Screen
+        name="VideoFeed"
+        getComponent={() => VideoFeed}
+        options={{
+          title: title(msg`Video Feed`),
+          requireAuth: true,
+        }}
+      />
     </>
   )
 }
diff --git a/src/alf/themes.ts b/src/alf/themes.ts
index cb97a7065..82b2e1b40 100644
--- a/src/alf/themes.ts
+++ b/src/alf/themes.ts
@@ -497,7 +497,7 @@ export function createThemes({
         color: dimPalette.contrast_400,
       },
       text_contrast_medium: {
-        color: dimPalette.contrast_700,
+        color: dimPalette.contrast_600,
       },
       text_contrast_high: {
         color: dimPalette.contrast_900,
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index c424321be..597964e29 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -27,6 +27,7 @@ import {useA11y} from '#/state/a11y'
 import {useDialogStateControlContext} from '#/state/dialogs'
 import {List, ListMethods, ListProps} from '#/view/com/util/List'
 import {atoms as a, useTheme} from '#/alf'
+import {useThemeName} from '#/alf/util/useColorModeTheme'
 import {Context, useDialogContext} from '#/components/Dialog/context'
 import {
   DialogControlProps,
@@ -55,7 +56,8 @@ export function Outer({
   nativeOptions,
   testID,
 }: React.PropsWithChildren<DialogOuterProps>) {
-  const t = useTheme()
+  const themeName = useThemeName()
+  const t = useTheme(themeName)
   const ref = React.useRef<BottomSheetNativeComponent>(null)
   const closeCallbacks = React.useRef<(() => void)[]>([])
   const {setDialogIsOpen, setFullyExpandedCount} =
diff --git a/src/components/Grid.tsx b/src/components/Grid.tsx
new file mode 100644
index 000000000..d424634de
--- /dev/null
+++ b/src/components/Grid.tsx
@@ -0,0 +1,59 @@
+import {createContext, useContext, useMemo} from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, ViewStyleProp} from '#/alf'
+
+const Context = createContext({
+  gap: 0,
+})
+
+export function Row({
+  children,
+  gap = 0,
+  style,
+}: ViewStyleProp & {
+  children: React.ReactNode
+  gap?: number
+}) {
+  return (
+    <Context.Provider value={useMemo(() => ({gap}), [gap])}>
+      <View
+        style={[
+          a.flex_row,
+          a.flex_1,
+          {
+            marginLeft: -gap / 2,
+            marginRight: -gap / 2,
+          },
+          style,
+        ]}>
+        {children}
+      </View>
+    </Context.Provider>
+  )
+}
+
+export function Col({
+  children,
+  width = 1,
+  style,
+}: ViewStyleProp & {
+  children: React.ReactNode
+  width?: number
+}) {
+  const {gap} = useContext(Context)
+  return (
+    <View
+      style={[
+        a.flex_col,
+        {
+          paddingLeft: gap / 2,
+          paddingRight: gap / 2,
+          width: `${width * 100}%`,
+        },
+        style,
+      ]}>
+      {children}
+    </View>
+  )
+}
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
index 2d0fc149e..d38cf9d94 100644
--- a/src/components/Layout/Header/index.tsx
+++ b/src/components/Layout/Header/index.tsx
@@ -122,7 +122,11 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
         shape="square"
         onPress={onPressBack}
         hitSlop={HITSLOP_30}
-        style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]}
+        style={[
+          {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
+          a.bg_transparent,
+          style,
+        ]}
         {...props}>
         <ButtonIcon icon={ArrowLeft} size="lg" />
       </Button>
diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx
index 724df43f3..9b28b897c 100644
--- a/src/components/LinearGradientBackground.tsx
+++ b/src/components/LinearGradientBackground.tsx
@@ -6,12 +6,18 @@ import {gradients} from '#/alf/tokens'
 
 export function LinearGradientBackground({
   style,
+  gradient = 'sky',
   children,
+  start,
+  end,
 }: {
-  style: StyleProp<ViewStyle>
-  children: React.ReactNode
+  style?: StyleProp<ViewStyle>
+  gradient?: keyof typeof gradients
+  children?: React.ReactNode
+  start?: [number, number]
+  end?: [number, number]
 }) {
-  const gradient = gradients.sky.values.map(([_, color]) => {
+  const colors = gradients[gradient].values.map(([_, color]) => {
     return color
   }) as [string, string, ...string[]]
 
@@ -20,7 +26,7 @@ export function LinearGradientBackground({
   }
 
   return (
-    <LinearGradient colors={gradient} style={style}>
+    <LinearGradient colors={colors} style={style} start={start} end={end}>
       {children}
     </LinearGradient>
   )
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
index 2d7b13b25..5c602249b 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -20,6 +20,7 @@ export function ListFooter({
   style,
   showEndMessage = false,
   endMessageText,
+  renderEndMessage,
 }: {
   isFetchingNextPage?: boolean
   hasNextPage?: boolean
@@ -29,6 +30,7 @@ export function ListFooter({
   style?: StyleProp<ViewStyle>
   showEndMessage?: boolean
   endMessageText?: string
+  renderEndMessage?: () => React.ReactNode
 }) {
   const t = useTheme()
 
@@ -48,9 +50,13 @@ export function ListFooter({
       ) : error ? (
         <ListFooterMaybeError error={error} onRetry={onRetry} />
       ) : !hasNextPage && showEndMessage ? (
-        <Text style={[a.text_sm, t.atoms.text_contrast_low]}>
-          {endMessageText ?? <Trans>You have reached the end</Trans>}
-        </Text>
+        renderEndMessage ? (
+          renderEndMessage()
+        ) : (
+          <Text style={[a.text_sm, t.atoms.text_contrast_low]}>
+            {endMessageText ?? <Trans>You have reached the end</Trans>}
+          </Text>
+        )
       ) : null}
     </View>
   )
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index 6d7e50e48..4edd9f88e 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -19,7 +19,7 @@ import {Text, TextProps} from '#/components/Typography'
 const WORD_WRAP = {wordWrap: 1}
 
 export type RichTextProps = TextStyleProp &
-  Pick<TextProps, 'selectable'> & {
+  Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
     value: RichTextAPI | string
     testID?: string
     numberOfLines?: number
@@ -43,6 +43,8 @@ export function RichText({
   onLinkPress,
   interactiveStyle,
   emojiMultiplier = 1.85,
+  onLayout,
+  onTextLayout,
 }: RichTextProps) {
   const richText = React.useMemo(
     () =>
@@ -70,6 +72,8 @@ export function RichText({
           selectable={selectable}
           testID={testID}
           style={[plainStyles, {fontSize}]}
+          onLayout={onLayout}
+          onTextLayout={onTextLayout}
           // @ts-ignore web only -prf
           dataSet={WORD_WRAP}>
           {text}
@@ -83,6 +87,8 @@ export function RichText({
         testID={testID}
         style={plainStyles}
         numberOfLines={numberOfLines}
+        onLayout={onLayout}
+        onTextLayout={onTextLayout}
         // @ts-ignore web only -prf
         dataSet={WORD_WRAP}>
         {text}
@@ -163,6 +169,8 @@ export function RichText({
       testID={testID}
       style={plainStyles}
       numberOfLines={numberOfLines}
+      onLayout={onLayout}
+      onTextLayout={onTextLayout}
       // @ts-ignore web only -prf
       dataSet={WORD_WRAP}>
       {els}
diff --git a/src/components/VideoPostCard.tsx b/src/components/VideoPostCard.tsx
new file mode 100644
index 000000000..008274969
--- /dev/null
+++ b/src/components/VideoPostCard.tsx
@@ -0,0 +1,540 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {LinearGradient} from 'expo-linear-gradient'
+import {
+  AppBskyActorDefs,
+  AppBskyEmbedVideo,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  ModerationDecision,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {formatCount} from '#/view/com/util/numeric/format'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
+import {atoms as a, useTheme} from '#/alf'
+import {BLUE_HUE} from '#/alf/util/colorGeneration'
+import {select} from '#/alf/util/themeSelector'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
+import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
+import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
+import {Link} from '#/components/Link'
+import {MediaInsetBorder} from '#/components/MediaInsetBorder'
+import * as Hider from '#/components/moderation/Hider'
+import {Text} from '#/components/Typography'
+
+function getBlackColor(t: ReturnType<typeof useTheme>) {
+  return select(t.name, {
+    light: t.palette.black,
+    dark: t.atoms.bg_contrast_25.backgroundColor,
+    dim: `hsl(${BLUE_HUE}, 28%, 6%)`,
+  })
+}
+
+export function VideoPostCard({
+  post,
+  sourceContext,
+  moderation,
+  onInteract,
+}: {
+  post: AppBskyFeedDefs.PostView
+  sourceContext: VideoFeedSourceContext
+  moderation: ModerationDecision
+  /**
+   * Callback for metrics etc
+   */
+  onInteract?: () => void
+}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const embed = post.embed
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  const listModUi = moderation.ui('contentList')
+
+  const mergedModui = useMemo(() => {
+    const modui = moderation.ui('contentList')
+    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])
+
+  /**
+   * Filtering should be done at a higher level, such as `PostFeed` or
+   * `PostFeedVideoGridRow`, but we need to protect here as well.
+   */
+  if (!AppBskyEmbedVideo.isView(embed)) return null
+
+  const author = post.author
+  const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : ''
+  const likeCount = post?.likeCount ?? 0
+  const repostCount = post?.repostCount ?? 0
+  const {thumbnail} = embed
+  const black = getBlackColor(t)
+
+  const textAndAuthor = (
+    <View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}>
+      {text && (
+        <Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji>
+          {text}
+        </Text>
+      )}
+      <View style={[a.flex_row, a.gap_xs, a.align_center]}>
+        <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
+          <UserAvatar type="user" size={20} avatar={post.author.avatar} />
+          <MediaInsetBorder />
+        </View>
+        <Text
+          style={[
+            a.flex_1,
+            a.text_sm,
+            a.leading_tight,
+            t.atoms.text_contrast_medium,
+          ]}
+          numberOfLines={1}>
+          {sanitizeHandle(post.author.handle, '@')}
+        </Text>
+      </View>
+    </View>
+  )
+
+  return (
+    <Link
+      accessibilityHint={_(msg`Tap to view video in immersive mode.`)}
+      label={_(msg`Video from ${author.handle}: ${text}`)}
+      to={{
+        screen: 'VideoFeed',
+        params: {
+          ...sourceContext,
+          initialPostUri: post.uri,
+        },
+      }}
+      onPress={() => {
+        onInteract?.()
+      }}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      style={[
+        a.flex_col,
+        {
+          alignItems: undefined,
+          justifyContent: undefined,
+        },
+      ]}>
+      <Hider.Outer modui={mergedModui}>
+        <Hider.Mask>
+          <View
+            style={[
+              a.justify_center,
+              a.rounded_md,
+              a.overflow_hidden,
+              {
+                backgroundColor: black,
+                aspectRatio: 9 / 16,
+              },
+            ]}>
+            <Image
+              source={{uri: thumbnail}}
+              style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
+              accessibilityIgnoresInvertColors
+              blurRadius={100}
+            />
+            <MediaInsetBorder />
+            <View
+              style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  a.justify_center,
+                  a.align_center,
+                  {
+                    backgroundColor: 'black',
+                    opacity: 0.2,
+                  },
+                ]}
+              />
+              <View style={[a.align_center, a.gap_xs]}>
+                <Eye size="lg" fill="white" />
+                <Text style={[a.text_sm, {color: 'white'}]}>
+                  {_(msg`Hidden`)}
+                </Text>
+              </View>
+            </View>
+          </View>
+          {listModUi.blur ? (
+            <VideoPostCardTextPlaceholder author={post.author} />
+          ) : (
+            textAndAuthor
+          )}
+        </Hider.Mask>
+        <Hider.Content>
+          <View
+            style={[
+              a.justify_center,
+              a.rounded_md,
+              a.overflow_hidden,
+              {
+                backgroundColor: black,
+                aspectRatio: 9 / 16,
+              },
+            ]}>
+            <Image
+              source={{uri: thumbnail}}
+              style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
+              accessibilityIgnoresInvertColors
+            />
+            <MediaInsetBorder />
+
+            <View style={[a.absolute, a.inset_0]}>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  a.pt_2xl,
+                  {
+                    top: 'auto',
+                  },
+                ]}>
+                <LinearGradient
+                  colors={[black, 'rgba(0, 0, 0, 0)']}
+                  locations={[0.02, 1]}
+                  start={{x: 0, y: 1}}
+                  end={{x: 0, y: 0}}
+                  style={[a.absolute, a.inset_0, {opacity: 0.9}]}
+                />
+
+                <View
+                  style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}>
+                  {likeCount > 0 && (
+                    <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+                      <Heart size="sm" fill="white" />
+                      <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
+                        {formatCount(i18n, likeCount)}
+                      </Text>
+                    </View>
+                  )}
+                  {repostCount > 0 && (
+                    <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+                      <Repost size="sm" fill="white" />
+                      <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
+                        {formatCount(i18n, repostCount)}
+                      </Text>
+                    </View>
+                  )}
+                </View>
+              </View>
+            </View>
+          </View>
+          {textAndAuthor}
+        </Hider.Content>
+      </Hider.Outer>
+    </Link>
+  )
+}
+
+export function VideoPostCardPlaceholder() {
+  const t = useTheme()
+  const black = getBlackColor(t)
+
+  return (
+    <View style={[a.flex_1]}>
+      <View
+        style={[
+          a.rounded_md,
+          a.overflow_hidden,
+          {
+            backgroundColor: black,
+            aspectRatio: 9 / 16,
+          },
+        ]}>
+        <MediaInsetBorder />
+      </View>
+      <VideoPostCardTextPlaceholder />
+    </View>
+  )
+}
+
+export function VideoPostCardTextPlaceholder({
+  author,
+}: {
+  author?: AppBskyActorDefs.ProfileViewBasic
+}) {
+  const t = useTheme()
+
+  return (
+    <View style={[a.flex_1]}>
+      <View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}>
+        <View
+          style={[
+            a.w_full,
+            a.rounded_xs,
+            t.atoms.bg_contrast_50,
+            {
+              height: 14,
+            },
+          ]}
+        />
+        <View
+          style={[
+            a.w_full,
+            a.rounded_xs,
+            t.atoms.bg_contrast_50,
+            {
+              height: 14,
+              width: '70%',
+            },
+          ]}
+        />
+        {author ? (
+          <View style={[a.flex_row, a.gap_xs, a.align_center]}>
+            <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
+              <UserAvatar type="user" size={20} avatar={author.avatar} />
+              <MediaInsetBorder />
+            </View>
+            <Text
+              style={[
+                a.flex_1,
+                a.text_sm,
+                a.leading_tight,
+                t.atoms.text_contrast_medium,
+              ]}
+              numberOfLines={1}>
+              {sanitizeHandle(author.handle, '@')}
+            </Text>
+          </View>
+        ) : (
+          <View style={[a.flex_row, a.gap_xs, a.align_center]}>
+            <View
+              style={[
+                a.rounded_full,
+                t.atoms.bg_contrast_50,
+                {
+                  width: 20,
+                  height: 20,
+                },
+              ]}
+            />
+            <View
+              style={[
+                a.rounded_xs,
+                t.atoms.bg_contrast_25,
+                {
+                  height: 12,
+                  width: '75%',
+                },
+              ]}
+            />
+          </View>
+        )}
+      </View>
+    </View>
+  )
+}
+
+export function CompactVideoPostCard({
+  post,
+  sourceContext,
+  moderation,
+  onInteract,
+}: {
+  post: AppBskyFeedDefs.PostView
+  sourceContext: VideoFeedSourceContext
+  moderation: ModerationDecision
+  /**
+   * Callback for metrics etc
+   */
+  onInteract?: () => void
+}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const embed = post.embed
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  const mergedModui = useMemo(() => {
+    const modui = moderation.ui('contentList')
+    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])
+
+  /**
+   * Filtering should be done at a higher level, such as `PostFeed` or
+   * `PostFeedVideoGridRow`, but we need to protect here as well.
+   */
+  if (!AppBskyEmbedVideo.isView(embed)) return null
+
+  const likeCount = post?.likeCount ?? 0
+  const {thumbnail} = embed
+  const black = getBlackColor(t)
+
+  return (
+    <Link
+      label={_(msg`View video`)}
+      to={{
+        screen: 'VideoFeed',
+        params: {
+          ...sourceContext,
+          initialPostUri: post.uri,
+        },
+      }}
+      onPress={() => {
+        onInteract?.()
+      }}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      style={[
+        a.flex_col,
+        {
+          alignItems: undefined,
+          justifyContent: undefined,
+        },
+      ]}>
+      <Hider.Outer modui={mergedModui}>
+        <Hider.Mask>
+          <View
+            style={[
+              a.justify_center,
+              a.rounded_md,
+              a.overflow_hidden,
+              {
+                backgroundColor: black,
+                aspectRatio: 9 / 16,
+              },
+            ]}>
+            <Image
+              source={{uri: thumbnail}}
+              style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
+              accessibilityIgnoresInvertColors
+              blurRadius={100}
+            />
+            <MediaInsetBorder />
+            <View
+              style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  a.justify_center,
+                  a.align_center,
+                  {
+                    backgroundColor: 'black',
+                    opacity: 0.2,
+                  },
+                ]}
+              />
+              <View style={[a.align_center, a.gap_xs]}>
+                <Eye size="lg" fill="white" />
+                <Text style={[a.text_sm, {color: 'white'}]}>
+                  {_(msg`Hidden`)}
+                </Text>
+              </View>
+            </View>
+          </View>
+        </Hider.Mask>
+        <Hider.Content>
+          <View
+            style={[
+              a.justify_center,
+              a.rounded_md,
+              a.overflow_hidden,
+              {
+                backgroundColor: black,
+                aspectRatio: 9 / 16,
+              },
+            ]}>
+            <Image
+              source={{uri: thumbnail}}
+              style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
+              accessibilityIgnoresInvertColors
+            />
+            <MediaInsetBorder />
+
+            <View style={[a.absolute, a.inset_0]}>
+              <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
+                <View
+                  style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
+                  <UserAvatar
+                    type="user"
+                    size={20}
+                    avatar={post.author.avatar}
+                  />
+                  <MediaInsetBorder />
+                </View>
+              </View>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  a.pt_2xl,
+                  {
+                    top: 'auto',
+                  },
+                ]}>
+                <LinearGradient
+                  colors={[black, 'rgba(0, 0, 0, 0)']}
+                  locations={[0.02, 1]}
+                  start={{x: 0, y: 1}}
+                  end={{x: 0, y: 0}}
+                  style={[a.absolute, a.inset_0, {opacity: 0.9}]}
+                />
+
+                <View
+                  style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
+                  {likeCount > 0 && (
+                    <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+                      <Heart size="sm" fill="white" />
+                      <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
+                        {formatCount(i18n, likeCount)}
+                      </Text>
+                    </View>
+                  )}
+                </View>
+              </View>
+            </View>
+          </View>
+        </Hider.Content>
+      </Hider.Outer>
+    </Link>
+  )
+}
+
+export function CompactVideoPostCardPlaceholder() {
+  const t = useTheme()
+  const black = getBlackColor(t)
+
+  return (
+    <View style={[a.flex_1]}>
+      <View
+        style={[
+          a.rounded_md,
+          a.overflow_hidden,
+          {
+            backgroundColor: black,
+            aspectRatio: 9 / 16,
+          },
+        ]}>
+        <MediaInsetBorder />
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/feeds/PostFeedVideoGridRow.tsx b/src/components/feeds/PostFeedVideoGridRow.tsx
new file mode 100644
index 000000000..7f9898083
--- /dev/null
+++ b/src/components/feeds/PostFeedVideoGridRow.tsx
@@ -0,0 +1,67 @@
+import {View} from 'react-native'
+import {AppBskyEmbedVideo} from '@atproto/api'
+
+import {logEvent} from '#/lib/statsig/statsig'
+import {FeedPostSliceItem} from '#/state/queries/post-feed'
+import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
+import {atoms as a, useGutters} from '#/alf'
+import * as Grid from '#/components/Grid'
+import {
+  VideoPostCard,
+  VideoPostCardPlaceholder,
+} from '#/components/VideoPostCard'
+
+export function PostFeedVideoGridRow({
+  items: slices,
+  sourceContext,
+}: {
+  items: FeedPostSliceItem[]
+  sourceContext: VideoFeedSourceContext
+}) {
+  const gutters = useGutters(['base', 'base', 0, 'base'])
+  const posts = slices
+    .filter(slice => AppBskyEmbedVideo.isView(slice.post.embed))
+    .map(slice => ({
+      post: slice.post,
+      moderation: slice.moderation,
+    }))
+
+  /**
+   * This should not happen because we should be filtering out posts without
+   * videos within the `PostFeed` component.
+   */
+  if (posts.length !== slices.length) return null
+
+  return (
+    <View style={[gutters]}>
+      <View style={[a.flex_row, a.gap_sm]}>
+        <Grid.Row gap={a.gap_sm.gap}>
+          {posts.map(post => (
+            <Grid.Col key={post.post.uri} width={1 / 2}>
+              <VideoPostCard
+                post={post.post}
+                sourceContext={sourceContext}
+                moderation={post.moderation}
+                onInteract={() => {
+                  logEvent('videoCard:click', {context: 'feed'})
+                }}
+              />
+            </Grid.Col>
+          ))}
+        </Grid.Row>
+      </View>
+    </View>
+  )
+}
+
+export function PostFeedVideoGridRowPlaceholder() {
+  const gutters = useGutters(['base', 'base', 0, 'base'])
+  return (
+    <View style={[gutters]}>
+      <View style={[a.flex_row, a.gap_sm]}>
+        <VideoPostCardPlaceholder />
+        <VideoPostCardPlaceholder />
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/interstitials/TrendingVideos.tsx b/src/components/interstitials/TrendingVideos.tsx
new file mode 100644
index 000000000..126d6f417
--- /dev/null
+++ b/src/components/interstitials/TrendingVideos.tsx
@@ -0,0 +1,231 @@
+import React, {useEffect} 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 {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 {useTrendingSettingsApi} from '#/state/preferences/trending'
+import {usePostFeedQuery} from '#/state/queries/post-feed'
+import {RQKEY} from '#/state/queries/post-feed'
+import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
+import {atoms as a, useGutters, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
+import {Link} from '#/components/Link'
+import * as Prompt from '#/components/Prompt'
+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: 'discover'
+} = {
+  feedCacheKey: 'discover',
+}
+
+export function TrendingVideos() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const gutters = useGutters([0, 'base'])
+  const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
+
+  // Refetch on unmount if nothing else is using this query.
+  const queryClient = useQueryClient()
+  useEffect(() => {
+    return () => {
+      const query = queryClient
+        .getQueryCache()
+        .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
+      if (query && query.getObserversCount() <= 1) {
+        query.fetch()
+      }
+    }
+  }, [queryClient])
+
+  const {setTrendingVideoDisabled} = useTrendingSettingsApi()
+  const trendingPrompt = Prompt.usePromptControl()
+
+  const onConfirmHide = React.useCallback(() => {
+    setTrendingVideoDisabled(true)
+    logEvent('trendingVideos:hide', {context: 'interstitial:discover'})
+  }, [setTrendingVideoDisabled])
+
+  if (error) {
+    return null
+  }
+
+  return (
+    <View
+      style={[
+        a.pt_lg,
+        a.pb_lg,
+        a.border_t,
+        t.atoms.border_contrast_low,
+        t.atoms.bg_contrast_25,
+      ]}>
+      <View
+        style={[
+          gutters,
+          a.pb_sm,
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+        ]}>
+        <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}>
+          <Graph />
+          <Text style={[a.text_md, a.font_bold, a.leading_snug]}>
+            <Trans>Trending Videos</Trans>
+          </Text>
+        </View>
+        <Button
+          label={_(msg`Dismiss this section`)}
+          size="tiny"
+          variant="ghost"
+          color="secondary"
+          shape="round"
+          onPress={() => trendingPrompt.open()}>
+          <ButtonIcon icon={X} />
+        </Button>
+      </View>
+
+      <BlockDrawerGesture>
+        <ScrollView
+          horizontal
+          showsHorizontalScrollIndicator={false}
+          decelerationRate="fast"
+          snapToInterval={CARD_WIDTH + a.gap_sm.gap}>
+          <View
+            style={[
+              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>
+
+      <Prompt.Basic
+        control={trendingPrompt}
+        title={_(msg`Hide trending videos?`)}
+        description={_(msg`You can update this later from your settings.`)}
+        confirmButtonCta={_(msg`Hide`)}
+        onConfirm={onConfirmHide}
+      />
+    </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, 'discover')
+  }, [])
+
+  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: 'discover',
+            }}
+            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,
+          ]}>
+          {({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/lib/constants.ts b/src/lib/constants.ts
index 5ae000f72..945e61c99 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -124,6 +124,11 @@ export const BSKY_FEED_OWNER_DIDS = [
 
 export const DISCOVER_FEED_URI =
   'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
+export const VIDEO_FEED_URI =
+  'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/thevids'
+export const STAGING_VIDEO_FEED_URI =
+  'at://did:plc:yofh3kx63drvfljkibw5zuxo/app.bsky.feed.generator/thevids'
+export const VIDEO_FEED_URIS = [VIDEO_FEED_URI, STAGING_VIDEO_FEED_URI]
 export const DISCOVER_SAVED_FEED = {
   type: 'feed',
   value: DISCOVER_FEED_URI,
diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts
index 8a9950262..10c99b62d 100644
--- a/src/lib/routes/links.ts
+++ b/src/lib/routes/links.ts
@@ -19,9 +19,13 @@ export function makeProfileLink(
 export function makeCustomFeedLink(
   did: string,
   rkey: string,
-  ...segments: string[]
+  segment?: string | undefined,
+  feedCacheKey?: 'discover' | 'explore' | undefined,
 ) {
-  return [`/profile`, did, 'feed', rkey, ...segments].join('/')
+  return (
+    [`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') +
+    (feedCacheKey ? `?feedCacheKey=${encodeURIComponent(feedCacheKey)}` : '')
+  )
 }
 
 export function makeListLink(did: string, rkey: string, ...segments: string[]) {
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index d720886e9..66ee7bffa 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -1,6 +1,8 @@
 import {NavigationState, PartialState} from '@react-navigation/native'
 import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
 
+import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
+
 export type {NativeStackScreenProps} from '@react-navigation/native-stack'
 
 export type CommonNavigatorParams = {
@@ -20,7 +22,11 @@ export type CommonNavigatorParams = {
   PostLikedBy: {name: string; rkey: string}
   PostRepostedBy: {name: string; rkey: string}
   PostQuotes: {name: string; rkey: string}
-  ProfileFeed: {name: string; rkey: string}
+  ProfileFeed: {
+    name: string
+    rkey: string
+    feedCacheKey?: 'discover' | 'explore' | undefined
+  }
   ProfileFeedLikedBy: {name: string; rkey: string}
   ProfileLabelerLikedBy: {name: string}
   Debug: undefined
@@ -57,6 +63,7 @@ export type CommonNavigatorParams = {
   StarterPackShort: {code: string}
   StarterPackWizard: undefined
   StarterPackEdit: {rkey?: string}
+  VideoFeed: VideoFeedSourceContext
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 19bf06ba9..af759e94e 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -131,16 +131,16 @@ export type LogEvents = {
     doesPosterFollowLiker: boolean | undefined
     likerClout: number | undefined
     postClout: number | undefined
-    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
   }
   'post:repost': {
-    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
   }
   'post:unlike': {
-    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
   }
   'post:unrepost': {
-    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
   }
   'post:mute': {}
   'post:unmute': {}
@@ -163,6 +163,7 @@ export type LogEvents = {
       | 'FeedInterstitial'
       | 'ProfileHeaderSuggestedFollows'
       | 'PostOnboardingFindFollows'
+      | 'ImmersiveVideo'
   }
   'profile:unfollow': {
     logContext:
@@ -179,6 +180,7 @@ export type LogEvents = {
       | 'FeedInterstitial'
       | 'ProfileHeaderSuggestedFollows'
       | 'PostOnboardingFindFollows'
+      | 'ImmersiveVideo'
   }
   'chat:create': {
     logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -249,6 +251,15 @@ export type LogEvents = {
   'recommendedTopic:click': {
     context: 'explore'
   }
+  'trendingVideos:show': {
+    context: 'settings'
+  }
+  'trendingVideos:hide': {
+    context: 'settings' | 'interstitial:discover' | 'interstitial:explore'
+  }
+  'videoCard:click': {
+    context: 'interstitial:discover' | 'interstitial:explore' | 'feed'
+  }
 
   'progressGuide:hide': {}
   'progressGuide:followDialog:open': {}
diff --git a/src/routes.ts b/src/routes.ts
index 7cd7c0880..8541d4254 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -64,4 +64,5 @@ export const router = new Router({
   StarterPack: '/starter-pack/:name/:rkey',
   StarterPackShort: '/starter-pack-short/:code',
   StarterPackWizard: '/starter-pack/create',
+  VideoFeed: '/video-feed',
 })
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
+    }
diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx
index de5157a54..2ad5ff91a 100644
--- a/src/state/feed-feedback.tsx
+++ b/src/state/feed-feedback.tsx
@@ -7,7 +7,7 @@ import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants'
 import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
-import {getFeedPostSlice} from '#/view/com/posts/PostFeed'
+import {getItemsForFeedback} from '#/view/com/posts/PostFeed'
 import {useAgent} from './session'
 
 type StateContext = {
@@ -102,18 +102,15 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
       if (!enabled) {
         return
       }
-      const slice = getFeedPostSlice(feedItem)
-      if (slice === null) {
-        return
-      }
-      for (const postItem of slice.items) {
+      const items = getItemsForFeedback(feedItem)
+      for (const {item: postItem, feedContext} of items) {
         if (!history.current.has(postItem)) {
           history.current.add(postItem)
           queue.current.add(
             toString({
               item: postItem.uri,
               event: 'app.bsky.feed.defs#interactionSeen',
-              feedContext: slice.feedContext,
+              feedContext,
             }),
           )
           sendToFeed()
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 0a9e5b2c0..f840081f3 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -126,6 +126,7 @@ const schema = z.object({
   /** @deprecated */
   mutedThreads: z.array(z.string()),
   trendingDisabled: z.boolean().optional(),
+  trendingVideoDisabled: z.boolean().optional(),
 })
 export type Schema = z.infer<typeof schema>
 
@@ -172,6 +173,7 @@ export const defaults: Schema = {
   hasCheckedForStarterPack: false,
   subtitlesEnabled: true,
   trendingDisabled: false,
+  trendingVideoDisabled: false,
 }
 
 export function tryParse(rawData: string): Schema | undefined {
diff --git a/src/state/preferences/trending.tsx b/src/state/preferences/trending.tsx
index bf5d8f13c..87ec68771 100644
--- a/src/state/preferences/trending.tsx
+++ b/src/state/preferences/trending.tsx
@@ -4,18 +4,27 @@ import * as persisted from '#/state/persisted'
 
 type StateContext = {
   trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined>
+  trendingVideoDisabled: Exclude<
+    persisted.Schema['trendingVideoDisabled'],
+    undefined
+  >
 }
 type ApiContext = {
   setTrendingDisabled(
     hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>,
   ): void
+  setTrendingVideoDisabled(
+    hidden: Exclude<persisted.Schema['trendingVideoDisabled'], undefined>,
+  ): void
 }
 
 const StateContext = React.createContext<StateContext>({
   trendingDisabled: Boolean(persisted.defaults.trendingDisabled),
+  trendingVideoDisabled: Boolean(persisted.defaults.trendingVideoDisabled),
 })
 const ApiContext = React.createContext<ApiContext>({
   setTrendingDisabled() {},
+  setTrendingVideoDisabled() {},
 })
 
 function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
@@ -43,14 +52,19 @@ function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
 export function Provider({children}: React.PropsWithChildren<{}>) {
   const [trendingDisabled, setTrendingDisabled] =
     usePersistedBooleanValue('trendingDisabled')
+  const [trendingVideoDisabled, setTrendingVideoDisabled] =
+    usePersistedBooleanValue('trendingVideoDisabled')
 
   /*
    * Context
    */
-  const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled])
+  const state = React.useMemo(
+    () => ({trendingDisabled, trendingVideoDisabled}),
+    [trendingDisabled, trendingVideoDisabled],
+  )
   const api = React.useMemo(
-    () => ({setTrendingDisabled}),
-    [setTrendingDisabled],
+    () => ({setTrendingDisabled, setTrendingVideoDisabled}),
+    [setTrendingDisabled, setTrendingVideoDisabled],
   )
 
   return (
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index e5ce19a9a..500cfea54 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -48,6 +48,7 @@ export type FeedSourceFeedInfo = {
   creatorHandle: string
   likeCount: number | undefined
   likeUri: string | undefined
+  contentMode: AppBskyFeedDefs.GeneratorView['contentMode']
 }
 
 export type FeedSourceListInfo = {
@@ -65,6 +66,7 @@ export type FeedSourceListInfo = {
   description: RichText
   creatorDid: string
   creatorHandle: string
+  contentMode: undefined
 }
 
 export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo
@@ -111,6 +113,7 @@ export function hydrateFeedGenerator(
     creatorHandle: view.creator.handle,
     likeCount: view.likeCount,
     likeUri: view.viewer?.like,
+    contentMode: view.contentMode,
   }
 }
 
@@ -141,6 +144,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
     displayName: view.name
       ? sanitizeDisplayName(view.name)
       : `User List by ${sanitizeHandle(view.creator.handle, '@')}`,
+    contentMode: undefined,
   }
 }
 
@@ -399,6 +403,7 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = {
     id: 'pwi-discover',
     ...DISCOVER_SAVED_FEED,
   },
+  contentMode: undefined,
 }
 
 const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
@@ -485,6 +490,7 @@ export function usePinnedFeedsInfos() {
             likeCount: 0,
             likeUri: '',
             savedFeed: pinnedItem,
+            contentMode: undefined,
           })
         }
       }
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 2eb604627..6f9af18f0 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -44,7 +44,7 @@ import {
 } from './util'
 
 type ActorDid = string
-type AuthorFilter =
+export type AuthorFilter =
   | 'posts_with_replies'
   | 'posts_no_replies'
   | 'posts_and_author_threads'
@@ -61,6 +61,7 @@ export type FeedDescriptor =
 export interface FeedParams {
   mergeFeedEnabled?: boolean
   mergeFeedSources?: string[]
+  feedCacheKey?: 'discover' | 'explore' | undefined
 }
 
 type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 10ed60212..f643adaf9 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -1,11 +1,12 @@
 import React from 'react'
 import {View} from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {NavigationProp, useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
+import {VIDEO_FEED_URIS} from '#/lib/constants'
 import {ComposeIcon2} from '#/lib/icons'
 import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
 import {AllNavigatorParams} from '#/lib/routes/types'
@@ -15,6 +16,7 @@ import {isNative} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
 import {useSetHomeBadge} from '#/state/home-badge'
+import {SavedFeedSourceInfo} from '#/state/queries/feed'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {truncateAndInvalidate} from '#/state/queries/util'
@@ -39,6 +41,7 @@ export function FeedPage({
   renderEmptyState,
   renderEndOfFeed,
   savedFeedConfig,
+  feedInfo,
 }: {
   testID?: string
   feed: FeedDescriptor
@@ -48,6 +51,7 @@ export function FeedPage({
   renderEmptyState: () => JSX.Element
   renderEndOfFeed?: () => JSX.Element
   savedFeedConfig?: AppBskyActorDefs.SavedFeed
+  feedInfo: SavedFeedSourceInfo
 }) {
   const {hasSession} = useSession()
   const {_} = useLingui()
@@ -61,6 +65,13 @@ export function FeedPage({
   const scrollElRef = React.useRef<ListMethods>(null)
   const [hasNew, setHasNew] = React.useState(false)
   const setHomeBadge = useSetHomeBadge()
+  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])
 
   React.useEffect(() => {
     if (isPageFocused) {
@@ -134,6 +145,7 @@ export function FeedPage({
             renderEndOfFeed={renderEndOfFeed}
             headerOffset={headerOffset}
             savedFeedConfig={savedFeedConfig}
+            isVideoFeed={isVideoFeed}
           />
         </FeedFeedbackProvider>
       </MainScrollProvider>
diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx
index 705572c06..40acff376 100644
--- a/src/view/com/post-thread/PostThreadComposePrompt.tsx
+++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx
@@ -40,7 +40,6 @@ export function PostThreadComposePrompt({
         t.atoms.border_contrast_low,
         t.atoms.bg,
       ]}
-      onPressIn={ios(() => playHaptic('Light'))}
       onPress={() => {
         onPressCompose()
         playHaptic('Light')
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index f9b2e6e76..554415faf 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -9,7 +9,7 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
@@ -20,7 +20,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {logEvent} from '#/lib/statsig/statsig'
 import {useTheme} from '#/lib/ThemeContext'
 import {logger} from '#/logger'
-import {isIOS, isWeb} from '#/platform/detection'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
 import {listenPostCreated} from '#/state/events'
 import {useFeedFeedbackContext} from '#/state/feed-feedback'
 import {useTrendingSettings} from '#/state/preferences/trending'
@@ -29,18 +29,24 @@ import {
   FeedDescriptor,
   FeedParams,
   FeedPostSlice,
+  FeedPostSliceItem,
   pollLatest,
   RQKEY,
   usePostFeedQuery,
 } from '#/state/queries/post-feed'
 import {useSession} from '#/state/session'
 import {useProgressGuide} from '#/state/shell/progress-guide'
+import {List, ListRef} from '#/view/com/util/List'
+import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
 import {useBreakpoints} from '#/alf'
 import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
+import {
+  PostFeedVideoGridRow,
+  PostFeedVideoGridRowPlaceholder,
+} from '#/components/feeds/PostFeedVideoGridRow'
 import {TrendingInterstitial} from '#/components/interstitials/Trending'
-import {List, ListRef} from '../util/List'
-import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
-import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
 import {FeedShutdownMsg} from './FeedShutdownMsg'
 import {PostFeedErrorMessage} from './PostFeedErrorMessage'
@@ -69,7 +75,7 @@ type FeedRow =
       key: string
     }
   | {
-      type: 'slice'
+      type: 'slice' // TODO can we remove?
       key: string
       slice: FeedPostSlice
     }
@@ -81,6 +87,17 @@ type FeedRow =
       showReplyTo: boolean
     }
   | {
+      type: 'videoGridRowPlaceholder'
+      key: string
+    }
+  | {
+      type: 'videoGridRow'
+      key: string
+      items: FeedPostSliceItem[]
+      sourceFeedUri: string
+      feedContexts: (string | undefined)[]
+    }
+  | {
       type: 'sliceViewFullThread'
       key: string
       uri: string
@@ -97,12 +114,28 @@ type FeedRow =
       type: 'interstitialTrending'
       key: string
     }
+  | {
+      type: 'interstitialTrendingVideos'
+      key: string
+    }
 
-export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null {
+export function getItemsForFeedback(feedRow: FeedRow):
+  | {
+      item: FeedPostSliceItem
+      feedContext: string | undefined
+    }[] {
   if (feedRow.type === 'sliceItem') {
-    return feedRow.slice
+    return feedRow.slice.items.map(item => ({
+      item,
+      feedContext: feedRow.slice.feedContext,
+    }))
+  } else if (feedRow.type === 'videoGridRow') {
+    return feedRow.items.map((item, i) => ({
+      item,
+      feedContext: feedRow.feedContexts[i],
+    }))
   } else {
-    return null
+    return []
   }
 }
 
@@ -131,6 +164,7 @@ let PostFeed = ({
   extraData,
   savedFeedConfig,
   initialNumToRender: initialNumToRenderOverride,
+  isVideoFeed = false,
 }: {
   feed: FeedDescriptor
   feedParams?: FeedParams
@@ -152,6 +186,7 @@ let PostFeed = ({
   extraData?: any
   savedFeedConfig?: AppBskyActorDefs.SavedFeed
   initialNumToRender?: number
+  isVideoFeed?: boolean
 }): React.ReactNode => {
   const theme = useTheme()
   const {_} = useLingui()
@@ -163,8 +198,10 @@ let PostFeed = ({
   const checkForNewRef = React.useRef<(() => void) | null>(null)
   const lastFetchRef = React.useRef<number>(Date.now())
   const [feedType, feedUri, feedTab] = feed.split('|')
-  const {gtTablet} = useBreakpoints()
+  const {gtMobile, gtTablet} = useBreakpoints()
+  const areVideoFeedsEnabled = isNative
 
+  const feedCacheKey = feedParams?.feedCacheKey
   const opts = React.useMemo(
     () => ({enabled, ignoreFilterFor}),
     [enabled, ignoreFilterFor],
@@ -267,10 +304,10 @@ let PostFeed = ({
   const showProgressIntersitial =
     (followProgressGuide || followAndLikeProgressGuide) && !isDesktop
 
-  const {trendingDisabled} = useTrendingSettings()
+  const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
 
   const feedItems: FeedRow[] = React.useMemo(() => {
-    let feedKind: 'following' | 'discover' | 'profile' | undefined
+    let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined
     if (feedType === 'following') {
       feedKind = 'following'
     } else if (feedUri === DISCOVER_FEED_URI) {
@@ -303,81 +340,132 @@ let PostFeed = ({
         })
       } else if (data) {
         let sliceIndex = -1
-        for (const page of data?.pages) {
-          for (const slice of page.slices) {
+
+        if (isVideoFeed) {
+          const videos: {
+            item: FeedPostSliceItem
+            feedContext: string | undefined
+          }[] = []
+          for (const page of data.pages) {
+            for (const slice of page.slices) {
+              const item = slice.items.at(0)
+              if (item && AppBskyEmbedVideo.isView(item.post.embed)) {
+                videos.push({item, feedContext: slice.feedContext})
+              }
+            }
+          }
+
+          const rows: {
+            item: FeedPostSliceItem
+            feedContext: string | undefined
+          }[][] = []
+          for (let i = 0; i < videos.length; i++) {
+            const video = videos[i]
+            const item = video.item
+            const cols = gtMobile ? 3 : 2
+            const rowItem = {item, feedContext: video.feedContext}
+            if (i % cols === 0) {
+              rows.push([rowItem])
+            } else {
+              rows[rows.length - 1].push(rowItem)
+            }
+          }
+
+          for (const row of rows) {
             sliceIndex++
+            arr.push({
+              type: 'videoGridRow',
+              key: row.map(r => r.item._reactKey).join('-'),
+              items: row.map(r => r.item),
+              sourceFeedUri: feedUri,
+              feedContexts: row.map(r => r.feedContext),
+            })
+          }
+        } else {
+          for (const page of data?.pages) {
+            for (const slice of page.slices) {
+              sliceIndex++
 
-            if (hasSession) {
-              if (feedKind === 'discover') {
-                if (sliceIndex === 0) {
-                  if (showProgressIntersitial) {
+              if (hasSession) {
+                if (feedKind === 'discover') {
+                  if (sliceIndex === 0) {
+                    if (showProgressIntersitial) {
+                      arr.push({
+                        type: 'interstitialProgressGuide',
+                        key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
+                      })
+                    }
+                    if (!gtTablet && !trendingDisabled) {
+                      arr.push({
+                        type: 'interstitialTrending',
+                        key:
+                          'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
+                      })
+                    }
+                  } else if (sliceIndex === 15) {
+                    if (areVideoFeedsEnabled && !trendingVideoDisabled) {
+                      arr.push({
+                        type: 'interstitialTrendingVideos',
+                        key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
+                      })
+                    }
+                  } else if (sliceIndex === 30) {
                     arr.push({
-                      type: 'interstitialProgressGuide',
+                      type: 'interstitialFollows',
                       key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
                     })
                   }
-                  if (!gtTablet && !trendingDisabled) {
+                } else if (feedKind === 'profile') {
+                  if (sliceIndex === 5) {
                     arr.push({
-                      type: 'interstitialTrending',
-                      key: 'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
+                      type: 'interstitialFollows',
+                      key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
                     })
                   }
-                } else if (sliceIndex === 30) {
-                  arr.push({
-                    type: 'interstitialFollows',
-                    key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
-                  })
-                }
-              } else if (feedKind === 'profile') {
-                if (sliceIndex === 5) {
-                  arr.push({
-                    type: 'interstitialFollows',
-                    key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
-                  })
                 }
               }
-            }
 
-            if (slice.isIncompleteThread && slice.items.length >= 3) {
-              const beforeLast = slice.items.length - 2
-              const last = slice.items.length - 1
-              arr.push({
-                type: 'sliceItem',
-                key: slice.items[0]._reactKey,
-                slice: slice,
-                indexInSlice: 0,
-                showReplyTo: false,
-              })
-              arr.push({
-                type: 'sliceViewFullThread',
-                key: slice._reactKey + '-viewFullThread',
-                uri: slice.items[0].uri,
-              })
-              arr.push({
-                type: 'sliceItem',
-                key: slice.items[beforeLast]._reactKey,
-                slice: slice,
-                indexInSlice: beforeLast,
-                showReplyTo:
-                  slice.items[beforeLast].parentAuthor?.did !==
-                  slice.items[beforeLast].post.author.did,
-              })
-              arr.push({
-                type: 'sliceItem',
-                key: slice.items[last]._reactKey,
-                slice: slice,
-                indexInSlice: last,
-                showReplyTo: false,
-              })
-            } else {
-              for (let i = 0; i < slice.items.length; i++) {
+              if (slice.isIncompleteThread && slice.items.length >= 3) {
+                const beforeLast = slice.items.length - 2
+                const last = slice.items.length - 1
+                arr.push({
+                  type: 'sliceItem',
+                  key: slice.items[0]._reactKey,
+                  slice: slice,
+                  indexInSlice: 0,
+                  showReplyTo: false,
+                })
+                arr.push({
+                  type: 'sliceViewFullThread',
+                  key: slice._reactKey + '-viewFullThread',
+                  uri: slice.items[0].uri,
+                })
                 arr.push({
                   type: 'sliceItem',
-                  key: slice.items[i]._reactKey,
+                  key: slice.items[beforeLast]._reactKey,
                   slice: slice,
-                  indexInSlice: i,
-                  showReplyTo: i === 0,
+                  indexInSlice: beforeLast,
+                  showReplyTo:
+                    slice.items[beforeLast].parentAuthor?.did !==
+                    slice.items[beforeLast].post.author.did,
                 })
+                arr.push({
+                  type: 'sliceItem',
+                  key: slice.items[last]._reactKey,
+                  slice: slice,
+                  indexInSlice: last,
+                  showReplyTo: false,
+                })
+              } else {
+                for (let i = 0; i < slice.items.length; i++) {
+                  arr.push({
+                    type: 'sliceItem',
+                    key: slice.items[i]._reactKey,
+                    slice: slice,
+                    indexInSlice: i,
+                    showReplyTo: i === 0,
+                  })
+                }
               }
             }
           }
@@ -390,10 +478,17 @@ let PostFeed = ({
         })
       }
     } else {
-      arr.push({
-        type: 'loading',
-        key: 'loading',
-      })
+      if (isVideoFeed) {
+        arr.push({
+          type: 'videoGridRowPlaceholder',
+          key: 'videoGridRowPlaceholder',
+        })
+      } else {
+        arr.push({
+          type: 'loading',
+          key: 'loading',
+        })
+      }
     }
 
     return arr
@@ -409,7 +504,11 @@ let PostFeed = ({
     hasSession,
     showProgressIntersitial,
     trendingDisabled,
+    trendingVideoDisabled,
     gtTablet,
+    gtMobile,
+    isVideoFeed,
+    areVideoFeedsEnabled,
   ])
 
   // events
@@ -498,6 +597,8 @@ let PostFeed = ({
         return <ProgressGuide />
       } else if (row.type === 'interstitialTrending') {
         return <TrendingInterstitial />
+      } else if (row.type === 'interstitialTrendingVideos') {
+        return <TrendingVideosInterstitial />
       } else if (row.type === 'sliceItem') {
         const slice = row.slice
         if (slice.isFallbackMarker) {
@@ -532,6 +633,25 @@ let PostFeed = ({
         )
       } else if (row.type === 'sliceViewFullThread') {
         return <ViewFullThread uri={row.uri} />
+      } else if (row.type === 'videoGridRowPlaceholder') {
+        return (
+          <View>
+            <PostFeedVideoGridRowPlaceholder />
+            <PostFeedVideoGridRowPlaceholder />
+            <PostFeedVideoGridRowPlaceholder />
+          </View>
+        )
+      } else if (row.type === 'videoGridRow') {
+        return (
+          <PostFeedVideoGridRow
+            items={row.items}
+            sourceContext={{
+              type: 'feedgen',
+              uri: row.sourceFeedUri,
+              sourceInterstitial: feedCacheKey ?? 'none',
+            }}
+          />
+        )
       } else {
         return null
       }
@@ -545,6 +665,7 @@ let PostFeed = ({
       _,
       onPressRetryLoadMore,
       feedUri,
+      feedCacheKey,
     ],
   )
 
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index a4e1a0947..41ca5b572 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -152,6 +152,9 @@ let List = React.forwardRef<ListMethods, ListProps>(
 
     return (
       <FlatList_INTERNAL
+        showsVerticalScrollIndicator={!isAndroid} // overridable
+        onViewableItemsChanged={onViewableItemsChanged}
+        viewabilityConfig={viewabilityConfig}
         {...props}
         automaticallyAdjustsScrollIndicatorInsets={
           automaticallyAdjustsScrollIndicatorInsets
@@ -166,9 +169,6 @@ let List = React.forwardRef<ListMethods, ListProps>(
         onScroll={scrollHandler}
         scrollsToTop={!activeLightbox}
         scrollEventThrottle={1}
-        onViewableItemsChanged={onViewableItemsChanged}
-        viewabilityConfig={viewabilityConfig}
-        showsVerticalScrollIndicator={!isAndroid}
         style={style}
         // @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn
         ref={ref}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 607a480ff..f73cede35 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -69,7 +69,7 @@ let PostCtrls = ({
   style?: StyleProp<ViewStyle>
   onPressReply: () => void
   onPostReply?: (postUri: string | undefined) => void
-  logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
   threadgateRecord?: AppBskyFeedThreadgate.Record
 }): React.ReactNode => {
   const t = useTheme()
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 59b296730..9043e2fdf 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -234,6 +234,7 @@ function HomeScreenReady({
                 feedParams={homeFeedParams}
                 renderEmptyState={renderFollowingEmptyState}
                 renderEndOfFeed={FollowingEndOfFeed}
+                feedInfo={feedInfo}
               />
             )
           }
@@ -247,6 +248,7 @@ function HomeScreenReady({
               feed={feed}
               renderEmptyState={renderCustomFeedEmptyState}
               savedFeedConfig={savedFeedConfig}
+              feedInfo={feedInfo}
             />
           )
         })
diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx
index e27435c35..c5af9607b 100644
--- a/src/view/screens/Search/Explore.tsx
+++ b/src/view/screens/Search/Explore.tsx
@@ -12,7 +12,7 @@ import {useLingui} from '@lingui/react'
 
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
+import {isNative, isWeb} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useGetPopularFeedsQuery} from '#/state/queries/feed'
 import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -26,6 +26,7 @@ import {
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations'
 import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics'
+import {ExploreTrendingVideos} from '#/screens/Search/components/ExploreTrendingVideos'
 import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
 import {Button} from '#/components/Button'
 import * as FeedCard from '#/components/FeedCard'
@@ -247,6 +248,10 @@ type ExploreScreenItems =
       key: string
     }
   | {
+      type: 'trendingVideos'
+      key: string
+    }
+  | {
       type: 'recommendations'
       key: string
     }
@@ -343,6 +348,13 @@ export function Explore() {
       key: `trending-topics`,
     })
 
+    if (isNative) {
+      i.push({
+        type: 'trendingVideos',
+        key: `trending-videos`,
+      })
+    }
+
     i.push({
       type: 'recommendations',
       key: `recommendations`,
@@ -514,6 +526,9 @@ export function Explore() {
         case 'trendingTopics': {
           return <ExploreTrendingTopics />
         }
+        case 'trendingVideos': {
+          return <ExploreTrendingVideos />
+        }
         case 'recommendations': {
           return <ExploreRecommendations />
         }
diff --git a/yarn.lock b/yarn.lock
index eb77184f9..1dba53850 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -72,15 +72,15 @@
     tlds "^1.234.0"
     zod "^3.23.8"
 
-"@atproto/api@^0.13.21":
-  version "0.13.21"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.21.tgz#8ee27a07e5a024b5bf32408d9bd623dd598ad1cc"
-  integrity sha512-iOxSj2YS3Fx9IPz1NivKrSsdYPNbBgpnUH7+WhKYAMvDFDUe2PZe7taau8wsUjJAu/H3S0Mk2TDh5e/7tCRwHA==
+"@atproto/api@^0.13.28":
+  version "0.13.28"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.28.tgz#b36d4ad9485724ec030e7292599f048ab62a9fcc"
+  integrity sha512-qBuEI5aNe2/KjmtmtLMilnpZc+FRAsAM3/5nFOQPEudUk388ctNsmKdz2Nti4OvCebn+50EB6V3lju596CTUNA==
   dependencies:
-    "@atproto/common-web" "^0.3.1"
-    "@atproto/lexicon" "^0.4.4"
+    "@atproto/common-web" "^0.3.2"
+    "@atproto/lexicon" "^0.4.5"
     "@atproto/syntax" "^0.3.1"
-    "@atproto/xrpc" "^0.6.5"
+    "@atproto/xrpc" "^0.6.6"
     await-lock "^2.2.2"
     multiformats "^9.9.0"
     tlds "^1.234.0"
@@ -169,6 +169,16 @@
     uint8arrays "3.0.0"
     zod "^3.23.8"
 
+"@atproto/common-web@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.2.tgz#4cf78ad4d24fed801882f3d35afc39bceccdff51"
+  integrity sha512-Vx0JtL1/CssJbFAb0UOdvTrkbUautsDfHNOXNTcX2vyPIxH9xOameSqLLunM1hZnOQbJwyjmQCt6TV+bhnanDg==
+  dependencies:
+    graphemer "^1.4.0"
+    multiformats "^9.9.0"
+    uint8arrays "3.0.0"
+    zod "^3.23.8"
+
 "@atproto/common@0.1.0":
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
@@ -283,6 +293,17 @@
     multiformats "^9.9.0"
     zod "^3.23.8"
 
+"@atproto/lexicon@^0.4.5":
+  version "0.4.5"
+  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.5.tgz#4fcf3731193c674286e9e8d677bbab5dd530b817"
+  integrity sha512-fljWqMGKn+XWtTprBcS3F1hGBREnQYh6qYHv2sjENucc7REms1gtmZXSerB9N6pVeHVNOnXiILdukeAcic5OEw==
+  dependencies:
+    "@atproto/common-web" "^0.3.2"
+    "@atproto/syntax" "^0.3.1"
+    iso-datestring-validator "^2.2.2"
+    multiformats "^9.9.0"
+    zod "^3.23.8"
+
 "@atproto/oauth-provider@^0.2.10":
   version "0.2.10"
   resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.10.tgz#f9820d7f82c33d3b74e81a75873f50e1e654b901"
@@ -452,6 +473,14 @@
     "@atproto/lexicon" "^0.4.4"
     zod "^3.23.8"
 
+"@atproto/xrpc@^0.6.6":
+  version "0.6.6"
+  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.6.tgz#28f58270ef4a8056f7f718bd52512e74bcd3702f"
+  integrity sha512-umXEYVMo9/pyIBoKmIAIi64RXDW9tSXY+wqztlQ6I2GZtjLfNZqmAWU+wADk3SxUe54mvjxxGyA4TtyGtDMfhA==
+  dependencies:
+    "@atproto/lexicon" "^0.4.5"
+    zod "^3.23.8"
+
 "@aws-crypto/crc32@3.0.0":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"