From a5437ebbeb9d2da84a1560d4c68d7c9ce42c5140 Mon Sep 17 00:00:00 2001
From: Samuel Newman <mozzius@protonmail.com>
Date: Mon, 4 Aug 2025 20:24:53 +0300
Subject: Fix web video ViewportObserver component (#8776)

* Revert "[APP-1083] bug fix: videos not accurately autoplaying on web (#8692)"

This reverts commit 9aa35e9fbb6136a88a66388ff5e4644ad25c9e4b.

* fix overflow hidden breaking the video viewport observer
---
 src/components/Post/Embed/VideoEmbed/index.web.tsx | 106 ++++++++++-----------
 1 file changed, 51 insertions(+), 55 deletions(-)

(limited to 'src/components/Post/Embed/VideoEmbed/index.web.tsx')

diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx
index 900bc2188..5bb54eef8 100644
--- a/src/components/Post/Embed/VideoEmbed/index.web.tsx
+++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx
@@ -1,4 +1,11 @@
-import {useCallback, useEffect, useRef, useState} from 'react'
+import {
+  createContext,
+  useCallback,
+  useContext,
+  useEffect,
+  useRef,
+  useState,
+} from 'react'
 import {View} from 'react-native'
 import {type AppBskyEmbedVideo} from '@atproto/api'
 import {msg} from '@lingui/macro'
@@ -83,9 +90,7 @@ export function VideoEmbed({
       style={{display: 'flex', flex: 1, cursor: 'default'}}
       onClick={evt => evt.stopPropagation()}>
       <ErrorBoundary renderError={renderError} key={key}>
-        <ViewportObserver
-          sendPosition={sendPosition}
-          isAnyViewActive={currentActiveView !== null}>
+        <OnlyNearScreen>
           <VideoEmbedInnerWeb
             embed={embed}
             active={active}
@@ -93,31 +98,39 @@ export function VideoEmbed({
             onScreen={onScreen}
             lastKnownTime={lastKnownTime}
           />
-        </ViewportObserver>
+        </OnlyNearScreen>
       </ErrorBoundary>
     </div>
   )
 
   return (
     <View style={[a.pt_xs]}>
-      {cropDisabled ? (
-        <View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}>
-          {contents}
-        </View>
-      ) : (
-        <ConstrainedImage
-          fullBleed={crop === 'square'}
-          aspectRatio={constrained || 1}>
-          {contents}
-        </ConstrainedImage>
-      )}
+      <ViewportObserver
+        sendPosition={sendPosition}
+        isAnyViewActive={currentActiveView !== null}>
+        {cropDisabled ? (
+          <View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}>
+            {contents}
+          </View>
+        ) : (
+          <ConstrainedImage
+            fullBleed={crop === 'square'}
+            aspectRatio={constrained || 1}>
+            {contents}
+          </ConstrainedImage>
+        )}
+      </ViewportObserver>
     </View>
   )
 }
 
+const NearScreenContext = createContext(false)
+
 /**
  * Renders a 100vh tall div and watches it with an IntersectionObserver to
  * send the position of the div when it's near the screen.
+ *
+ * IMPORTANT: ViewportObserver _must_ not be within a `overflow: hidden` container.
  */
 function ViewportObserver({
   children,
@@ -138,53 +151,22 @@ function ViewportObserver({
   useEffect(() => {
     if (!ref.current) return
     if (isFullscreen && !isFirefox) return
-
-    let scrollTimeout: NodeJS.Timeout | null = null
-    let lastObserverEntry: IntersectionObserverEntry | null = null
-
-    const updatePositionFromEntry = () => {
-      if (!lastObserverEntry) return
-      const rect = lastObserverEntry.boundingClientRect
-      const position = rect.y + rect.height / 2
-      sendPosition(position)
-    }
-
-    const handleScroll = () => {
-      if (scrollTimeout) {
-        clearTimeout(scrollTimeout)
-      }
-      scrollTimeout = setTimeout(updatePositionFromEntry, 4) // ~240fps
-    }
-
     const observer = new IntersectionObserver(
       entries => {
         const entry = entries[0]
         if (!entry) return
-        lastObserverEntry = entry
-        setNearScreen(entry.isIntersecting)
-        const rect = entry.boundingClientRect
-        const position = rect.y + rect.height / 2
+        const position =
+          entry.boundingClientRect.y + entry.boundingClientRect.height / 2
         sendPosition(position)
+        setNearScreen(entry.isIntersecting)
       },
-      {threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0]},
+      {threshold: Array.from({length: 101}, (_, i) => i / 100)},
     )
-
     observer.observe(ref.current)
+    return () => observer.disconnect()
+  }, [sendPosition, isFullscreen])
 
-    if (nearScreen) {
-      window.addEventListener('scroll', handleScroll, {passive: true})
-    }
-
-    return () => {
-      observer.disconnect()
-      if (scrollTimeout) {
-        clearTimeout(scrollTimeout)
-      }
-      window.removeEventListener('scroll', handleScroll)
-    }
-  }, [sendPosition, isFullscreen, nearScreen])
-
-  // In case scrolling hasn't started yet, send the original position
+  // In case scrolling hasn't started yet, send up the position
   useEffect(() => {
     if (ref.current && !isAnyViewActive) {
       const rect = ref.current.getBoundingClientRect()
@@ -195,7 +177,9 @@ function ViewportObserver({
 
   return (
     <View style={[a.flex_1, a.flex_row]}>
-      {nearScreen && children}
+      <NearScreenContext.Provider value={nearScreen}>
+        {children}
+      </NearScreenContext.Provider>
       <div
         ref={ref}
         style={{
@@ -213,6 +197,18 @@ function ViewportObserver({
   )
 }
 
+/**
+ * Awkward data flow here, but we need to hide the video when it's not near the screen.
+ * But also, ViewportObserver _must_ not be within a `overflow: hidden` container.
+ * So we put it at the top level of the component tree here, then hide the children of
+ * the auto-resizing container.
+ */
+export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => {
+  const nearScreen = useContext(NearScreenContext)
+
+  return nearScreen ? children : null
+}
+
 function VideoError({error, retry}: {error: unknown; retry: () => void}) {
   const {_} = useLingui()
 
-- 
cgit 1.4.1

