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 {View} from 'react-native'
import {type AppBskyEmbedVideo} from '@atproto/api' import {type AppBskyEmbedVideo} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
@@ -83,9 +90,7 @@ export function VideoEmbed({
style={{display: 'flex', flex: 1, cursor: 'default'}} style={{display: 'flex', flex: 1, cursor: 'default'}}
onClick={evt => evt.stopPropagation()}> onClick={evt => evt.stopPropagation()}>
<ErrorBoundary renderError={renderError} key={key}> <ErrorBoundary renderError={renderError} key={key}>
<ViewportObserver <OnlyNearScreen>
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
<VideoEmbedInnerWeb <VideoEmbedInnerWeb
embed={embed} embed={embed}
active={active} active={active}
@@ -93,31 +98,39 @@ export function VideoEmbed({
onScreen={onScreen} onScreen={onScreen}
lastKnownTime={lastKnownTime} lastKnownTime={lastKnownTime}
/> />
</ViewportObserver> </OnlyNearScreen>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
) )
return ( return (
<View style={[a.pt_xs]}> <View style={[a.pt_xs]}>
{cropDisabled ? ( <ViewportObserver
<View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}> sendPosition={sendPosition}
{contents} isAnyViewActive={currentActiveView !== null}>
</View> {cropDisabled ? (
) : ( <View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}>
<ConstrainedImage {contents}
fullBleed={crop === 'square'} </View>
aspectRatio={constrained || 1}> ) : (
{contents} <ConstrainedImage
</ConstrainedImage> fullBleed={crop === 'square'}
)} aspectRatio={constrained || 1}>
{contents}
</ConstrainedImage>
)}
</ViewportObserver>
</View> </View>
) )
} }
const NearScreenContext = createContext(false)
/** /**
* Renders a 100vh tall div and watches it with an IntersectionObserver to * Renders a 100vh tall div and watches it with an IntersectionObserver to
* send the position of the div when it's near the screen. * send the position of the div when it's near the screen.
*
* IMPORTANT: ViewportObserver _must_ not be within a `overflow: hidden` container.
*/ */
function ViewportObserver({ function ViewportObserver({
children, children,
@@ -138,53 +151,22 @@ function ViewportObserver({
useEffect(() => { useEffect(() => {
if (!ref.current) return if (!ref.current) return
if (isFullscreen && !isFirefox) 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( const observer = new IntersectionObserver(
entries => { entries => {
const entry = entries[0] const entry = entries[0]
if (!entry) return if (!entry) return
lastObserverEntry = entry const position =
setNearScreen(entry.isIntersecting) entry.boundingClientRect.y + entry.boundingClientRect.height / 2
const rect = entry.boundingClientRect
const position = rect.y + rect.height / 2
sendPosition(position) 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) observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition, isFullscreen])
if (nearScreen) { // In case scrolling hasn't started yet, send up the position
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
useEffect(() => { useEffect(() => {
if (ref.current && !isAnyViewActive) { if (ref.current && !isAnyViewActive) {
const rect = ref.current.getBoundingClientRect() const rect = ref.current.getBoundingClientRect()
@@ -195,7 +177,9 @@ function ViewportObserver({
return ( return (
<View style={[a.flex_1, a.flex_row]}> <View style={[a.flex_1, a.flex_row]}>
{nearScreen && children} <NearScreenContext.Provider value={nearScreen}>
{children}
</NearScreenContext.Provider>
<div <div
ref={ref} ref={ref}
style={{ 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}) { function VideoError({error, retry}: {error: unknown; retry: () => void}) {
const {_} = useLingui() const {_} = useLingui()