about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-08-07 14:45:06 -0700
committerGitHub <noreply@github.com>2024-08-07 14:45:06 -0700
commit1b02f81cb85333462e3a9a42accc05d09aca4f2c (patch)
tree766e80438c1f109a1a7d751e9f04b7f6242f9766
parentfff2c079c2554861764974aaeeb56f79a25ba82a (diff)
downloadvoidsky-1b02f81cb85333462e3a9a42accc05d09aca4f2c.tar.zst
[Video] Visibility detection view (#4741)
Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
-rw-r--r--jest/jestSetup.js3
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt23
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt63
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt82
-rw-r--r--modules/expo-bluesky-swiss-army/expo-module.config.json8
-rw-r--r--modules/expo-bluesky-swiss-army/index.ts3
-rw-r--r--modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift21
-rw-r--r--modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift86
-rw-r--r--modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift69
-rw-r--r--modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx39
-rw-r--r--modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx10
-rw-r--r--modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts6
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/screens/Profile/Sections/Feed.tsx1
-rw-r--r--src/view/com/notifications/Feed.tsx1
-rw-r--r--src/view/com/posts/Feed.tsx1
-rw-r--r--src/view/com/posts/FeedItem.tsx2
-rw-r--r--src/view/com/util/List.tsx5
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.tsx47
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.web.tsx8
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner.tsx143
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx96
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx3
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx3
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner.web.tsx)14
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx (renamed from src/view/com/util/post-embeds/VideoWebControls.tsx)0
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx (renamed from src/view/com/util/post-embeds/VideoWebControls.web.tsx)4
27 files changed, 564 insertions, 178 deletions
diff --git a/jest/jestSetup.js b/jest/jestSetup.js
index ac175900e..a68c1dc4b 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -104,4 +104,7 @@ jest.mock('expo-modules-core', () => ({
       }
     }
   }),
+  requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
+    return () => null
+  }),
 }))
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt
new file mode 100644
index 000000000..ddbb05cde
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt
@@ -0,0 +1,23 @@
+package expo.modules.blueskyswissarmy.visibilityview
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoBlueskyVisibilityViewModule : Module() {
+  override fun definition() =
+    ModuleDefinition {
+      Name("ExpoBlueskyVisibilityView")
+
+      AsyncFunction("updateActiveViewAsync") {
+        VisibilityViewManager.updateActiveView()
+      }
+
+      View(VisibilityView::class) {
+        Events(arrayOf("onChangeStatus"))
+
+        Prop("enabled") { view: VisibilityView, prop: Boolean ->
+          view.isViewEnabled = prop
+        }
+      }
+    }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt
new file mode 100644
index 000000000..a55ab80d5
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt
@@ -0,0 +1,63 @@
+package expo.modules.blueskyswissarmy.visibilityview
+
+import android.content.Context
+import android.graphics.Rect
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.viewevent.EventDispatcher
+import expo.modules.kotlin.views.ExpoView
+
+class VisibilityView(
+  context: Context,
+  appContext: AppContext,
+) : ExpoView(context, appContext) {
+  var isViewEnabled: Boolean = false
+
+  private val onChangeStatus by EventDispatcher()
+
+  private var isCurrentlyActive = false
+
+  override fun onAttachedToWindow() {
+    super.onAttachedToWindow()
+    VisibilityViewManager.addView(this)
+  }
+
+  override fun onDetachedFromWindow() {
+    super.onDetachedFromWindow()
+    VisibilityViewManager.removeView(this)
+  }
+
+  fun setIsCurrentlyActive(isActive: Boolean) {
+    if (isCurrentlyActive == isActive) {
+      return
+    }
+
+    this.isCurrentlyActive = isActive
+    this.onChangeStatus(
+      mapOf(
+        "isActive" to isActive,
+      ),
+    )
+  }
+
+  fun getPositionOnScreen(): Rect? {
+    if (!this.isShown) {
+      return null
+    }
+
+    val screenPosition = intArrayOf(0, 0)
+    this.getLocationInWindow(screenPosition)
+    return Rect(
+      screenPosition[0],
+      screenPosition[1],
+      screenPosition[0] + this.width,
+      screenPosition[1] + this.height,
+    )
+  }
+
+  fun isViewableEnough(): Boolean {
+    val positionOnScreen = this.getPositionOnScreen() ?: return false
+    val visibleArea = positionOnScreen.width() * positionOnScreen.height()
+    val totalArea = this.width * this.height
+    return visibleArea >= 0.5 * totalArea
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt
new file mode 100644
index 000000000..ec1e49816
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt
@@ -0,0 +1,82 @@
+package expo.modules.blueskyswissarmy.visibilityview
+
+import android.graphics.Rect
+
+class VisibilityViewManager {
+  companion object {
+    private val views = HashMap<Int, VisibilityView>()
+    private var currentlyActiveView: VisibilityView? = null
+    private var prevCount = 0
+
+    fun addView(view: VisibilityView) {
+      this.views[view.id] = view
+
+      if (this.prevCount == 0) {
+        this.updateActiveView()
+      }
+      this.prevCount = this.views.count()
+    }
+
+    fun removeView(view: VisibilityView) {
+      this.views.remove(view.id)
+      this.prevCount = this.views.count()
+    }
+
+    fun updateActiveView() {
+      var activeView: VisibilityView? = null
+      val count = this.views.count()
+
+      if (count == 1) {
+        val view = this.views.values.first()
+        if (view.isViewableEnough()) {
+          activeView = view
+        }
+      } else if (count > 1) {
+        val views = this.views.values
+        var mostVisibleView: VisibilityView? = null
+        var mostVisiblePosition: Rect? = null
+
+        views.forEach { view ->
+          if (!view.isViewableEnough()) {
+            return
+          }
+
+          val position = view.getPositionOnScreen() ?: return@forEach
+          val topY = position.centerY() - (position.height() / 2)
+
+          if (topY >= 150) {
+            if (mostVisiblePosition == null) {
+              mostVisiblePosition = position
+            }
+
+            if (position.centerY() <= mostVisiblePosition!!.centerY()) {
+              mostVisibleView = view
+              mostVisiblePosition = position
+            }
+          }
+        }
+
+        activeView = mostVisibleView
+      }
+
+      if (activeView == this.currentlyActiveView) {
+        return
+      }
+
+      this.clearActiveView()
+      if (activeView != null) {
+        this.setActiveView(activeView)
+      }
+    }
+
+    private fun clearActiveView() {
+      this.currentlyActiveView?.setIsCurrentlyActive(false)
+      this.currentlyActiveView = null
+    }
+
+    private fun setActiveView(view: VisibilityView) {
+      view.setIsCurrentlyActive(true)
+      this.currentlyActiveView = view
+    }
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json
index adb535e7f..4cdc11e99 100644
--- a/modules/expo-bluesky-swiss-army/expo-module.config.json
+++ b/modules/expo-bluesky-swiss-army/expo-module.config.json
@@ -1,12 +1,18 @@
 {
   "platforms": ["ios", "tvos", "android", "web"],
   "ios": {
-    "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoPlatformInfoModule"]
+    "modules": [
+      "ExpoBlueskySharedPrefsModule",
+      "ExpoBlueskyReferrerModule",
+      "ExpoBlueskyVisibilityViewModule",
+      "ExpoPlatformInfoModule"
+    ]
   },
   "android": {
     "modules": [
       "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
       "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
+      "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
       "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
     ]
   }
diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts
index f62596cb7..ebd67913e 100644
--- a/modules/expo-bluesky-swiss-army/index.ts
+++ b/modules/expo-bluesky-swiss-army/index.ts
@@ -1,5 +1,6 @@
 import * as PlatformInfo from './src/PlatformInfo'
 import * as Referrer from './src/Referrer'
 import * as SharedPrefs from './src/SharedPrefs'
+import VisibilityView from './src/VisibilityView'
 
-export {PlatformInfo, Referrer, SharedPrefs}
+export {PlatformInfo, Referrer, SharedPrefs, VisibilityView}
diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift
new file mode 100644
index 000000000..ec12a84af
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift
@@ -0,0 +1,21 @@
+import ExpoModulesCore
+
+public class ExpoBlueskyVisibilityViewModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("ExpoBlueskyVisibilityView")
+
+    AsyncFunction("updateActiveViewAsync") {
+      VisibilityViewManager.shared.updateActiveView()
+    }
+
+    View(VisibilityView.self) {
+      Events([
+        "onChangeStatus"
+      ])
+
+      Prop("enabled") { (view: VisibilityView, prop: Bool) in
+        view.enabled = prop
+      }
+    }
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift
new file mode 100644
index 000000000..ae8e16868
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift
@@ -0,0 +1,86 @@
+import Foundation
+
+class VisibilityViewManager {
+  static let shared = VisibilityViewManager()
+
+  private let views = NSHashTable<VisibilityView>(options: .weakMemory)
+  private var currentlyActiveView: VisibilityView?
+  private var screenHeight: CGFloat = UIScreen.main.bounds.height
+  private var prevCount = 0
+
+  func addView(_ view: VisibilityView) {
+    self.views.add(view)
+
+    if self.prevCount == 0 {
+      self.updateActiveView()
+    }
+    self.prevCount = self.views.count
+  }
+
+  func removeView(_ view: VisibilityView) {
+    self.views.remove(view)
+    self.prevCount = self.views.count
+  }
+
+  func updateActiveView() {
+    DispatchQueue.main.async {
+      var activeView: VisibilityView?
+
+      if self.views.count == 1 {
+        let view = self.views.allObjects[0]
+        if view.isViewableEnough() {
+          activeView = view
+        }
+      } else if self.views.count > 1 {
+        let views = self.views.allObjects
+        var mostVisibleView: VisibilityView?
+        var mostVisiblePosition: CGRect?
+
+        views.forEach { view in
+          if !view.isViewableEnough() {
+            return
+          }
+
+          guard let position = view.getPositionOnScreen() else {
+            return
+          }
+
+          if position.minY >= 150 {
+            if mostVisiblePosition == nil {
+              mostVisiblePosition = position
+            }
+
+            if let unwrapped = mostVisiblePosition,
+               position.minY <= unwrapped.minY {
+              mostVisibleView = view
+              mostVisiblePosition = position
+            }
+          }
+        }
+
+        activeView = mostVisibleView
+      }
+
+      if activeView == self.currentlyActiveView {
+        return
+      }
+
+      self.clearActiveView()
+      if let view = activeView {
+        self.setActiveView(view)
+      }
+    }
+  }
+
+  private func clearActiveView() {
+    if let currentlyActiveView = self.currentlyActiveView {
+      currentlyActiveView.setIsCurrentlyActive(isActive: false)
+      self.currentlyActiveView = nil
+    }
+  }
+
+  private func setActiveView(_ view: VisibilityView) {
+    view.setIsCurrentlyActive(isActive: true)
+    self.currentlyActiveView = view
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift
new file mode 100644
index 000000000..fd99ee493
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift
@@ -0,0 +1,69 @@
+import ExpoModulesCore
+
+class VisibilityView: ExpoView {
+  var enabled = false {
+    didSet {
+      if enabled {
+        VisibilityViewManager.shared.removeView(self)
+      }
+    }
+  }
+
+  private let onChangeStatus = EventDispatcher()
+  private var isCurrentlyActiveView = false
+
+  required init(appContext: AppContext? = nil) {
+    super.init(appContext: appContext)
+  }
+
+  public override func willMove(toWindow newWindow: UIWindow?) {
+    super.willMove(toWindow: newWindow)
+
+    if !self.enabled {
+      return
+    }
+
+    if newWindow == nil {
+      VisibilityViewManager.shared.removeView(self)
+    } else {
+      VisibilityViewManager.shared.addView(self)
+    }
+  }
+
+  func setIsCurrentlyActive(isActive: Bool) {
+    if isCurrentlyActiveView == isActive {
+      return
+    }
+    self.isCurrentlyActiveView = isActive
+    self.onChangeStatus([
+      "isActive": isActive
+    ])
+  }
+}
+
+// 🚨 DANGER 🚨
+// These functions need to be called from the main thread. Xcode will warn you if you call one of them
+// off the main thread, so pay attention!
+extension UIView {
+  func getPositionOnScreen() -> CGRect? {
+    if let window = self.window {
+      return self.convert(self.bounds, to: window)
+    }
+    return nil
+  }
+
+  func isViewableEnough() -> Bool {
+    guard let window = self.window else {
+      return false
+    }
+
+    let viewFrameOnScreen = self.convert(self.bounds, to: window)
+    let screenBounds = window.bounds
+    let intersection = viewFrameOnScreen.intersection(screenBounds)
+
+    let viewHeight = viewFrameOnScreen.height
+    let intersectionHeight = intersection.height
+
+    return intersectionHeight >= 0.5 * viewHeight
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx
new file mode 100644
index 000000000..9d0e8cf22
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
+import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
+
+import {VisibilityViewProps} from './types'
+const NativeView: React.ComponentType<{
+  onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
+  children: React.ReactNode
+  enabled: Boolean
+  style: StyleProp<ViewStyle>
+}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
+
+const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView')
+
+export async function updateActiveViewAsync() {
+  await NativeModule.updateActiveViewAsync()
+}
+
+export default function VisibilityView({
+  children,
+  onChangeStatus: onChangeStatusOuter,
+  enabled,
+}: VisibilityViewProps) {
+  const onChangeStatus = React.useCallback(
+    (e: {nativeEvent: {isActive: boolean}}) => {
+      onChangeStatusOuter(e.nativeEvent.isActive)
+    },
+    [onChangeStatusOuter],
+  )
+
+  return (
+    <NativeView
+      onChangeStatus={onChangeStatus}
+      enabled={enabled}
+      style={{flex: 1}}>
+      {children}
+    </NativeView>
+  )
+}
diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx
new file mode 100644
index 000000000..8b4f1928c
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx
@@ -0,0 +1,10 @@
+import {NotImplementedError} from '../NotImplemented'
+import {VisibilityViewProps} from './types'
+
+export async function updateActiveViewAsync() {
+  throw new NotImplementedError()
+}
+
+export default function VisibilityView({children}: VisibilityViewProps) {
+  return children
+}
diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts
new file mode 100644
index 000000000..312acf2d2
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts
@@ -0,0 +1,6 @@
+import React from 'react'
+export interface VisibilityViewProps {
+  children: React.ReactNode
+  onChangeStatus: (isActive: boolean) => void
+  enabled: boolean
+}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 58a60232b..4b482b47d 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -13,5 +13,6 @@ export type Gate =
   | 'suggested_feeds_interstitial'
   | 'suggested_follows_interstitial'
   | 'ungroup_follow_backs'
+  | 'video_debug'
   | 'videos'
   | 'small_avi_thumb'
diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx
index 201c8f7e0..e7ceaab0c 100644
--- a/src/screens/Profile/Sections/Feed.tsx
+++ b/src/screens/Profile/Sections/Feed.tsx
@@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
         headerOffset={headerHeight}
         renderEndOfFeed={ProfileEndOfFeed}
         ignoreFilterFor={ignoreFilterFor}
+        outsideHeaderOffset={headerHeight}
       />
       {(isScrolledDown || hasNew) && (
         <LoadLatestBtn
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 82755de1d..bd39ddd84 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -194,6 +194,7 @@ export function Feed({
         initialNumToRender={initialNumToRender}
         windowSize={11}
         sideBorders={false}
+        removeClippedSubviews={true}
       />
     </View>
   )
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 46bf4a5fd..aa45d3acc 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -180,6 +180,7 @@ let Feed = ({
   ListHeaderComponent?: () => JSX.Element
   extraData?: any
   savedFeedConfig?: AppBskyActorDefs.SavedFeed
+  outsideHeaderOffset?: number
 }): React.ReactNode => {
   const theme = useTheme()
   const {track} = useAnalytics()
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index a6e721d43..6660a8d9d 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -356,7 +356,7 @@ let FeedItemInner = ({
             postAuthor={post.author}
             onOpenEmbed={onOpenEmbed}
           />
-          {__DEV__ && gate('videos') && (
+          {gate('video_debug') && (
             <VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
           )}
           <PostCtrls
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 9d9b1d802..c62ac5ed1 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -5,7 +5,9 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useScrollHandlers} from '#/lib/ScrollContext'
+import {useDedupe} from 'lib/hooks/useDedupe'
 import {addStyle} from 'lib/styles'
+import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
 import {FlatList_INTERNAL} from './Views'
 
 export type ListMethods = FlatList_INTERNAL
@@ -47,6 +49,7 @@ function ListImpl<ItemT>(
 ) {
   const isScrolledDown = useSharedValue(false)
   const pal = usePalette('default')
+  const dedupe = useDedupe()
 
   function handleScrolledDownChange(didScrollDown: boolean) {
     onScrolledDownChange?.(didScrollDown)
@@ -77,6 +80,8 @@ function ListImpl<ItemT>(
           runOnJS(handleScrolledDownChange)(didScrollDown)
         }
       }
+
+      runOnJS(dedupe)(updateActiveViewAsync)
     },
     // Note: adding onMomentumBegin here makes simulator scroll
     // lag on Android. So either don't add it, or figure out why.
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index 429312d9e..887efac1a 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,21 +1,20 @@
-import React, {useCallback} from 'react'
+import React from 'react'
 import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonIcon} from '#/components/Button'
 import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
+import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
 import {useActiveVideoView} from './ActiveVideoContext'
-import {VideoEmbedInner} from './VideoEmbedInner'
 
 export function VideoEmbed({source}: {source: string}) {
   const t = useTheme()
   const {active, setActive} = useActiveVideoView({source})
   const {_} = useLingui()
 
-  const onPress = useCallback(() => setActive(), [setActive])
-
   return (
     <View
       style={[
@@ -26,25 +25,27 @@ export function VideoEmbed({source}: {source: string}) {
         t.atoms.bg_contrast_25,
         a.my_xs,
       ]}>
-      {active ? (
-        <VideoEmbedInner
-          source={source}
-          // web only
-          active={active}
-          setActive={setActive}
-          onScreen={true}
-        />
-      ) : (
-        <Button
-          style={[a.flex_1, t.atoms.bg_contrast_25]}
-          onPress={onPress}
-          label={_(msg`Play video`)}
-          variant="ghost"
-          color="secondary"
-          size="large">
-          <ButtonIcon icon={PlayIcon} />
-        </Button>
-      )}
+      <VisibilityView
+        enabled={true}
+        onChangeStatus={isActive => {
+          if (isActive) {
+            setActive()
+          }
+        }}>
+        {active ? (
+          <VideoEmbedInnerNative />
+        ) : (
+          <Button
+            style={[a.flex_1, t.atoms.bg_contrast_25]}
+            onPress={setActive}
+            label={_(msg`Play video`)}
+            variant="ghost"
+            color="secondary"
+            size="large">
+            <ButtonIcon icon={PlayIcon} />
+          </Button>
+        )}
+      </VisibilityView>
     </View>
   )
 }
diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
index 08932f91f..70d887283 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
@@ -3,13 +3,15 @@ import {View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {
+  HLSUnsupportedError,
+  VideoEmbedInnerWeb,
+} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import {Text} from '#/components/Typography'
 import {ErrorBoundary} from '../ErrorBoundary'
 import {useActiveVideoView} from './ActiveVideoContext'
-import {VideoEmbedInner} from './VideoEmbedInner'
-import {HLSUnsupportedError} from './VideoEmbedInner.web'
 
 export function VideoEmbed({source}: {source: string}) {
   const t = useTheme()
@@ -60,7 +62,7 @@ export function VideoEmbed({source}: {source: string}) {
           <ViewportObserver
             sendPosition={sendPosition}
             isAnyViewActive={currentActiveView !== null}>
-            <VideoEmbedInner
+            <VideoEmbedInnerWeb
               source={source}
               active={active}
               setActive={setActive}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
deleted file mode 100644
index 9b1fd54fb..000000000
--- a/src/view/com/util/post-embeds/VideoEmbedInner.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react'
-import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
-import Animated, {
-  measure,
-  runOnJS,
-  useAnimatedRef,
-  useFrameCallback,
-  useSharedValue,
-} from 'react-native-reanimated'
-import {VideoPlayer, VideoView} from 'expo-video'
-
-import {atoms as a} from '#/alf'
-import {Text} from '#/components/Typography'
-import {useVideoPlayer} from './VideoPlayerContext'
-
-export function VideoEmbedInner({}: {
-  source: string
-  active: boolean
-  setActive: () => void
-  onScreen: boolean
-}) {
-  const player = useVideoPlayer()
-  const aref = useAnimatedRef<Animated.View>()
-  const {height: windowHeight} = useWindowDimensions()
-  const hasLeftView = useSharedValue(false)
-  const ref = useRef<VideoView>(null)
-
-  const onEnterView = useCallback(() => {
-    if (player.status === 'readyToPlay') {
-      player.play()
-    }
-  }, [player])
-
-  const onLeaveView = useCallback(() => {
-    player.pause()
-  }, [player])
-
-  const enterFullscreen = useCallback(() => {
-    if (ref.current) {
-      ref.current.enterFullscreen()
-    }
-  }, [])
-
-  useFrameCallback(() => {
-    const measurement = measure(aref)
-
-    if (measurement) {
-      if (hasLeftView.value) {
-        // Check if the video is in view
-        if (
-          measurement.pageY >= 0 &&
-          measurement.pageY + measurement.height <= windowHeight
-        ) {
-          runOnJS(onEnterView)()
-          hasLeftView.value = false
-        }
-      } else {
-        // Check if the video is out of view
-        if (
-          measurement.pageY + measurement.height < 0 ||
-          measurement.pageY > windowHeight
-        ) {
-          runOnJS(onLeaveView)()
-          hasLeftView.value = true
-        }
-      }
-    }
-  })
-
-  return (
-    <Animated.View
-      style={[a.flex_1, a.relative]}
-      ref={aref}
-      collapsable={false}>
-      <VideoView
-        ref={ref}
-        player={player}
-        style={a.flex_1}
-        nativeControls={true}
-      />
-      <VideoControls player={player} enterFullscreen={enterFullscreen} />
-    </Animated.View>
-  )
-}
-
-function VideoControls({
-  player,
-  enterFullscreen,
-}: {
-  player: VideoPlayer
-  enterFullscreen: () => void
-}) {
-  const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime))
-
-  useEffect(() => {
-    const interval = setInterval(() => {
-      setCurrentTime(Math.floor(player.duration - player.currentTime))
-      // how often should we update the time?
-      // 1000 gets out of sync with the video time
-    }, 250)
-
-    return () => {
-      clearInterval(interval)
-    }
-  }, [player])
-
-  const minutes = Math.floor(currentTime / 60)
-  const seconds = String(currentTime % 60).padStart(2, '0')
-
-  return (
-    <View style={[a.absolute, a.inset_0]}>
-      <View style={styles.timeContainer} pointerEvents="none">
-        <Text style={styles.timeElapsed}>
-          {minutes}:{seconds}
-        </Text>
-      </View>
-      <Pressable
-        onPress={enterFullscreen}
-        style={a.flex_1}
-        accessibilityLabel="Video"
-        accessibilityHint="Tap to enter full screen"
-        accessibilityRole="button"
-      />
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  timeContainer: {
-    backgroundColor: 'rgba(0, 0, 0, 0.75)',
-    borderRadius: 6,
-    paddingHorizontal: 6,
-    paddingVertical: 3,
-    position: 'absolute',
-    left: 5,
-    bottom: 5,
-  },
-  timeElapsed: {
-    color: 'white',
-    fontSize: 12,
-    fontWeight: 'bold',
-  },
-})
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
new file mode 100644
index 000000000..cc356fb06
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -0,0 +1,96 @@
+import React, {useEffect, useRef, useState} from 'react'
+import {Pressable, View} from 'react-native'
+import {VideoPlayer, VideoView} from 'expo-video'
+
+import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext'
+import {android, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function VideoEmbedInnerNative() {
+  const player = useVideoPlayer()
+  const ref = useRef<VideoView>(null)
+
+  return (
+    <View style={[a.flex_1, a.relative]} collapsable={false}>
+      <VideoView
+        ref={ref}
+        player={player}
+        style={a.flex_1}
+        nativeControls={true}
+      />
+      <Controls
+        player={player}
+        enterFullscreen={() => ref.current?.enterFullscreen()}
+      />
+    </View>
+  )
+}
+
+function Controls({
+  player,
+  enterFullscreen,
+}: {
+  player: VideoPlayer
+  enterFullscreen: () => void
+}) {
+  const [duration, setDuration] = useState(() => Math.floor(player.duration))
+  const [currentTime, setCurrentTime] = useState(() =>
+    Math.floor(player.currentTime),
+  )
+
+  const timeRemaining = duration - currentTime
+  const minutes = Math.floor(timeRemaining / 60)
+  const seconds = String(timeRemaining % 60).padStart(2, '0')
+
+  useEffect(() => {
+    const interval = setInterval(() => {
+      // duration gets reset to 0 on loop
+      if (player.duration) setDuration(Math.floor(player.duration))
+      setCurrentTime(Math.floor(player.currentTime))
+      // how often should we update the time?
+      // 1000 gets out of sync with the video time
+    }, 250)
+
+    return () => {
+      clearInterval(interval)
+    }
+  }, [player])
+
+  if (isNaN(timeRemaining)) {
+    return null
+  }
+
+  return (
+    <View style={[a.absolute, a.inset_0]}>
+      <View
+        style={[
+          {
+            backgroundColor: 'rgba(0, 0, 0, 0.75',
+            borderRadius: 6,
+            paddingHorizontal: 6,
+            paddingVertical: 3,
+            position: 'absolute',
+            left: 5,
+            bottom: 5,
+          },
+        ]}
+        pointerEvents="none">
+        <Text
+          style={[
+            {color: 'white', fontSize: 12},
+            a.font_bold,
+            android({lineHeight: 1.25}),
+          ]}>
+          {minutes}:{seconds}
+        </Text>
+      </View>
+      <Pressable
+        onPress={enterFullscreen}
+        style={a.flex_1}
+        accessibilityLabel="Video"
+        accessibilityHint="Tap to enter full screen"
+        accessibilityRole="button"
+      />
+    </View>
+  )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
new file mode 100644
index 000000000..59da5be42
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
@@ -0,0 +1,3 @@
+export function VideoEmbedInnerNative() {
+  throw new Error('VideoEmbedInnerNative may not be used on native.')
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
new file mode 100644
index 000000000..8664aae14
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
@@ -0,0 +1,3 @@
+export function VideoEmbedInnerWeb() {
+  throw new Error('VideoEmbedInnerWeb may not be used on native.')
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index f5f47db50..c0021d9bb 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -5,17 +5,23 @@ import Hls from 'hls.js'
 import {atoms as a} from '#/alf'
 import {Controls} from './VideoWebControls'
 
-export function VideoEmbedInner({
+export function VideoEmbedInnerWeb({
   source,
   active,
   setActive,
   onScreen,
 }: {
   source: string
-  active: boolean
-  setActive: () => void
-  onScreen: boolean
+  active?: boolean
+  setActive?: () => void
+  onScreen?: boolean
 }) {
+  if (active == null || setActive == null || onScreen == null) {
+    throw new Error(
+      'active, setActive, and onScreen are required VideoEmbedInner props on web.',
+    )
+  }
+
   const containerRef = useRef<HTMLDivElement>(null)
   const ref = useRef<HTMLVideoElement>(null)
   const [focused, setFocused] = useState(false)
diff --git a/src/view/com/util/post-embeds/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
index 11e0867e4..11e0867e4 100644
--- a/src/view/com/util/post-embeds/VideoWebControls.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
diff --git a/src/view/com/util/post-embeds/VideoWebControls.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx
index 2843664be..7caaf3abf 100644
--- a/src/view/com/util/post-embeds/VideoWebControls.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx
@@ -11,12 +11,12 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import type Hls from 'hls.js'
 
-import {isIPhoneWeb} from '#/platform/detection'
+import {isIPhoneWeb} from 'platform/detection'
 import {
   useAutoplayDisabled,
   useSetSubtitlesEnabled,
   useSubtitlesEnabled,
-} from '#/state/preferences'
+} from 'state/preferences'
 import {atoms as a, useTheme, web} from '#/alf'
 import {Button} from '#/components/Button'
 import {useInteractionState} from '#/components/hooks/useInteractionState'