Bluesky app fork with some witchin' additions 馃挮
at readme-update 235 lines 7.1 kB view raw
1import { 2 createContext, 3 useCallback, 4 useContext, 5 useEffect, 6 useRef, 7 useState, 8} from 'react' 9import {View} from 'react-native' 10import {type AppBskyEmbedVideo} from '@atproto/api' 11import {msg} from '@lingui/macro' 12import {useLingui} from '@lingui/react' 13 14import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 15import {atoms as a, useTheme} from '#/alf' 16import {useIsWithinMessage} from '#/components/dms/MessageContext' 17import {useFullscreen} from '#/components/hooks/useFullscreen' 18import {ConstrainedImage} from '#/components/images/AutoSizedImage' 19import {MediaInsetBorder} from '#/components/MediaInsetBorder' 20import { 21 HLSUnsupportedError, 22 VideoEmbedInnerWeb, 23 VideoNotFoundError, 24} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb' 25import {IS_WEB_FIREFOX} from '#/env' 26import {useActiveVideoWeb} from './ActiveVideoWebContext' 27import * as VideoFallback from './VideoEmbedInner/VideoFallback' 28 29export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { 30 const t = useTheme() 31 const ref = useRef<HTMLDivElement>(null) 32 const {active, setActive, sendPosition, currentActiveView} = 33 useActiveVideoWeb() 34 const [onScreen, setOnScreen] = useState(false) 35 const [isFullscreen] = useFullscreen() 36 const lastKnownTime = useRef<number | undefined>(undefined) 37 38 useEffect(() => { 39 if (!ref.current) return 40 if (isFullscreen && !IS_WEB_FIREFOX) return 41 const observer = new IntersectionObserver( 42 entries => { 43 const entry = entries[0] 44 if (!entry) return 45 setOnScreen(entry.isIntersecting) 46 sendPosition( 47 entry.boundingClientRect.y + entry.boundingClientRect.height / 2, 48 ) 49 }, 50 {threshold: 0.5}, 51 ) 52 observer.observe(ref.current) 53 return () => observer.disconnect() 54 }, [sendPosition, isFullscreen]) 55 56 const [key, setKey] = useState(0) 57 const renderError = useCallback( 58 (error: unknown) => ( 59 <VideoError error={error} retry={() => setKey(key + 1)} /> 60 ), 61 [key], 62 ) 63 64 let aspectRatio: number | undefined 65 const dims = embed.aspectRatio 66 if (dims) { 67 aspectRatio = dims.width / dims.height 68 if (Number.isNaN(aspectRatio)) { 69 aspectRatio = undefined 70 } 71 } 72 73 let constrained: number | undefined 74 if (aspectRatio !== undefined) { 75 const ratio = 1 / 2 // max of 1:2 ratio in feeds 76 constrained = Math.max(aspectRatio, ratio) 77 } 78 79 const contents = ( 80 <div 81 ref={ref} 82 style={{ 83 display: 'flex', 84 flex: 1, 85 cursor: 'default', 86 backgroundColor: t.palette.black, 87 backgroundImage: `url(${embed.thumbnail})`, 88 backgroundSize: 'contain', 89 backgroundPosition: 'center', 90 backgroundRepeat: 'no-repeat', 91 }} 92 onClick={evt => evt.stopPropagation()}> 93 <ErrorBoundary renderError={renderError} key={key}> 94 <OnlyNearScreen> 95 <VideoEmbedInnerWeb 96 embed={embed} 97 active={active} 98 setActive={setActive} 99 onScreen={onScreen} 100 lastKnownTime={lastKnownTime} 101 /> 102 </OnlyNearScreen> 103 </ErrorBoundary> 104 </div> 105 ) 106 107 return ( 108 <View style={[a.pt_xs]}> 109 <ViewportObserver 110 sendPosition={sendPosition} 111 isAnyViewActive={currentActiveView !== null}> 112 <ConstrainedImage 113 fullBleed 114 aspectRatio={constrained || 1} 115 // slightly smaller max height than images 116 // images use 16 / 9, for reference 117 minMobileAspectRatio={14 / 9}> 118 {contents} 119 <MediaInsetBorder /> 120 </ConstrainedImage> 121 </ViewportObserver> 122 </View> 123 ) 124} 125 126const NearScreenContext = createContext(false) 127NearScreenContext.displayName = 'VideoNearScreenContext' 128 129/** 130 * Renders a 100vh tall div and watches it with an IntersectionObserver to 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. 134 */ 135function ViewportObserver({ 136 children, 137 sendPosition, 138 isAnyViewActive, 139}: { 140 children: React.ReactNode 141 sendPosition: (position: number) => void 142 isAnyViewActive: boolean 143}) { 144 const ref = useRef<HTMLDivElement>(null) 145 const [nearScreen, setNearScreen] = useState(false) 146 const [isFullscreen] = useFullscreen() 147 const isWithinMessage = useIsWithinMessage() 148 149 // Send position when scrolling. This is done with an IntersectionObserver 150 // observing a div of 100vh height 151 useEffect(() => { 152 if (!ref.current) return 153 if (isFullscreen && !IS_WEB_FIREFOX) return 154 const observer = new IntersectionObserver( 155 entries => { 156 const entry = entries[0] 157 if (!entry) return 158 const position = 159 entry.boundingClientRect.y + entry.boundingClientRect.height / 2 160 sendPosition(position) 161 setNearScreen(entry.isIntersecting) 162 }, 163 {threshold: Array.from({length: 101}, (_, i) => i / 100)}, 164 ) 165 observer.observe(ref.current) 166 return () => observer.disconnect() 167 }, [sendPosition, isFullscreen]) 168 169 // In case scrolling hasn't started yet, send up the position 170 useEffect(() => { 171 if (ref.current && !isAnyViewActive) { 172 const rect = ref.current.getBoundingClientRect() 173 const position = rect.y + rect.height / 2 174 sendPosition(position) 175 } 176 }, [isAnyViewActive, sendPosition]) 177 178 return ( 179 <View style={[a.flex_1, a.flex_row]}> 180 <NearScreenContext.Provider value={nearScreen}> 181 {children} 182 </NearScreenContext.Provider> 183 <div 184 ref={ref} 185 style={{ 186 // Don't escape bounds when in a message 187 ...(isWithinMessage 188 ? {top: 0, height: '100%'} 189 : {top: 'calc(50% - 50vh)', height: '100vh'}), 190 position: 'absolute', 191 left: '50%', 192 width: 1, 193 pointerEvents: 'none', 194 }} 195 /> 196 </View> 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 */ 206export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => { 207 const nearScreen = useContext(NearScreenContext) 208 209 return nearScreen ? children : null 210} 211 212function VideoError({error, retry}: {error: unknown; retry: () => void}) { 213 const {_} = useLingui() 214 215 let showRetryButton = true 216 let text = null 217 218 if (error instanceof VideoNotFoundError) { 219 text = _(msg`Video not found.`) 220 } else if (error instanceof HLSUnsupportedError) { 221 showRetryButton = false 222 text = _( 223 msg`Your browser does not support the video format. Please try a different browser.`, 224 ) 225 } else { 226 text = _(msg`An error occurred while loading the video. Please try again.`) 227 } 228 229 return ( 230 <VideoFallback.Container> 231 <VideoFallback.Text>{text}</VideoFallback.Text> 232 {showRetryButton && <VideoFallback.RetryButton onPress={retry} />} 233 </VideoFallback.Container> 234 ) 235}