Bluesky app fork with some witchin' additions 💫

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

authored by samuel.fm and committed by

GitHub a5437ebb 15bc7732

+51 -55
+51 -55
src/components/Post/Embed/VideoEmbed/index.web.tsx
··· 1 - import {useCallback, useEffect, useRef, useState} from 'react' 1 + import { 2 + createContext, 3 + useCallback, 4 + useContext, 5 + useEffect, 6 + useRef, 7 + useState, 8 + } from 'react' 2 9 import {View} from 'react-native' 3 10 import {type AppBskyEmbedVideo} from '@atproto/api' 4 11 import {msg} from '@lingui/macro' ··· 83 90 style={{display: 'flex', flex: 1, cursor: 'default'}} 84 91 onClick={evt => evt.stopPropagation()}> 85 92 <ErrorBoundary renderError={renderError} key={key}> 86 - <ViewportObserver 87 - sendPosition={sendPosition} 88 - isAnyViewActive={currentActiveView !== null}> 93 + <OnlyNearScreen> 89 94 <VideoEmbedInnerWeb 90 95 embed={embed} 91 96 active={active} ··· 93 98 onScreen={onScreen} 94 99 lastKnownTime={lastKnownTime} 95 100 /> 96 - </ViewportObserver> 101 + </OnlyNearScreen> 97 102 </ErrorBoundary> 98 103 </div> 99 104 ) 100 105 101 106 return ( 102 107 <View style={[a.pt_xs]}> 103 - {cropDisabled ? ( 104 - <View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}> 105 - {contents} 106 - </View> 107 - ) : ( 108 - <ConstrainedImage 109 - fullBleed={crop === 'square'} 110 - aspectRatio={constrained || 1}> 111 - {contents} 112 - </ConstrainedImage> 113 - )} 108 + <ViewportObserver 109 + sendPosition={sendPosition} 110 + isAnyViewActive={currentActiveView !== null}> 111 + {cropDisabled ? ( 112 + <View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}> 113 + {contents} 114 + </View> 115 + ) : ( 116 + <ConstrainedImage 117 + fullBleed={crop === 'square'} 118 + aspectRatio={constrained || 1}> 119 + {contents} 120 + </ConstrainedImage> 121 + )} 122 + </ViewportObserver> 114 123 </View> 115 124 ) 116 125 } 117 126 127 + const NearScreenContext = createContext(false) 128 + 118 129 /** 119 130 * Renders a 100vh tall div and watches it with an IntersectionObserver to 120 131 * send the position of the div when it's near the screen. 132 + * 133 + * IMPORTANT: ViewportObserver _must_ not be within a `overflow: hidden` container. 121 134 */ 122 135 function ViewportObserver({ 123 136 children, ··· 138 151 useEffect(() => { 139 152 if (!ref.current) return 140 153 if (isFullscreen && !isFirefox) return 141 - 142 - let scrollTimeout: NodeJS.Timeout | null = null 143 - let lastObserverEntry: IntersectionObserverEntry | null = null 144 - 145 - const updatePositionFromEntry = () => { 146 - if (!lastObserverEntry) return 147 - const rect = lastObserverEntry.boundingClientRect 148 - const position = rect.y + rect.height / 2 149 - sendPosition(position) 150 - } 151 - 152 - const handleScroll = () => { 153 - if (scrollTimeout) { 154 - clearTimeout(scrollTimeout) 155 - } 156 - scrollTimeout = setTimeout(updatePositionFromEntry, 4) // ~240fps 157 - } 158 - 159 154 const observer = new IntersectionObserver( 160 155 entries => { 161 156 const entry = entries[0] 162 157 if (!entry) return 163 - lastObserverEntry = entry 158 + const position = 159 + entry.boundingClientRect.y + entry.boundingClientRect.height / 2 160 + sendPosition(position) 164 161 setNearScreen(entry.isIntersecting) 165 - const rect = entry.boundingClientRect 166 - const position = rect.y + rect.height / 2 167 - sendPosition(position) 168 162 }, 169 - {threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0]}, 163 + {threshold: Array.from({length: 101}, (_, i) => i / 100)}, 170 164 ) 171 - 172 165 observer.observe(ref.current) 173 - 174 - if (nearScreen) { 175 - window.addEventListener('scroll', handleScroll, {passive: true}) 176 - } 166 + return () => observer.disconnect() 167 + }, [sendPosition, isFullscreen]) 177 168 178 - return () => { 179 - observer.disconnect() 180 - if (scrollTimeout) { 181 - clearTimeout(scrollTimeout) 182 - } 183 - window.removeEventListener('scroll', handleScroll) 184 - } 185 - }, [sendPosition, isFullscreen, nearScreen]) 186 - 187 - // In case scrolling hasn't started yet, send the original position 169 + // In case scrolling hasn't started yet, send up the position 188 170 useEffect(() => { 189 171 if (ref.current && !isAnyViewActive) { 190 172 const rect = ref.current.getBoundingClientRect() ··· 195 177 196 178 return ( 197 179 <View style={[a.flex_1, a.flex_row]}> 198 - {nearScreen && children} 180 + <NearScreenContext.Provider value={nearScreen}> 181 + {children} 182 + </NearScreenContext.Provider> 199 183 <div 200 184 ref={ref} 201 185 style={{ ··· 211 195 /> 212 196 </View> 213 197 ) 198 + } 199 + 200 + /** 201 + * Awkward data flow here, but we need to hide the video when it's not near the screen. 202 + * But also, ViewportObserver _must_ not be within a `overflow: hidden` container. 203 + * So we put it at the top level of the component tree here, then hide the children of 204 + * the auto-resizing container. 205 + */ 206 + export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => { 207 + const nearScreen = useContext(NearScreenContext) 208 + 209 + return nearScreen ? children : null 214 210 } 215 211 216 212 function VideoError({error, retry}: {error: unknown; retry: () => void}) {