Fix web video ViewportObserver component (#8776)

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

This reverts commit 9aa35e9fbb.

* fix overflow hidden breaking the video viewport observer
This commit is contained in:
Samuel Newman
2025-08-04 20:24:53 +03:00
committed by GitHub
parent 15bc77321b
commit a5437ebbeb
@@ -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()