Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 248 lines 7.5 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/core/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 29const noop = () => {} 30 31export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { 32 const t = useTheme() 33 const ref = useRef<HTMLDivElement>(null) 34 const { 35 active: activeFromContext, 36 setActive, 37 sendPosition, 38 currentActiveView, 39 } = useActiveVideoWeb() 40 const [onScreen, setOnScreen] = useState(false) 41 const [isFullscreen] = useFullscreen() 42 const lastKnownTime = useRef<number | undefined>(undefined) 43 44 const isGif = embed.presentation === 'gif' 45 // GIFs don't participate in the "one video at a time" system 46 const active = isGif || activeFromContext 47 48 useEffect(() => { 49 if (!ref.current) return 50 if (isFullscreen && !IS_WEB_FIREFOX) return 51 const observer = new IntersectionObserver( 52 entries => { 53 const entry = entries[0] 54 if (!entry) return 55 setOnScreen(entry.isIntersecting) 56 // GIFs don't send position - they don't compete to be the active video 57 if (!isGif) { 58 sendPosition( 59 entry.boundingClientRect.y + entry.boundingClientRect.height / 2, 60 ) 61 } 62 }, 63 {threshold: 0.5}, 64 ) 65 observer.observe(ref.current) 66 return () => observer.disconnect() 67 }, [sendPosition, isFullscreen, isGif]) 68 69 const [key, setKey] = useState(0) 70 const renderError = useCallback( 71 (error: unknown) => ( 72 <VideoError error={error} retry={() => setKey(key + 1)} /> 73 ), 74 [key], 75 ) 76 77 let aspectRatio: number | undefined 78 const dims = embed.aspectRatio 79 if (dims) { 80 aspectRatio = dims.width / dims.height 81 if (Number.isNaN(aspectRatio)) { 82 aspectRatio = undefined 83 } 84 } 85 86 let constrained: number | undefined 87 if (aspectRatio !== undefined) { 88 const ratio = 1 / 2 // max of 1:2 ratio in feeds 89 constrained = Math.max(aspectRatio, ratio) 90 } 91 92 const contents = ( 93 <div 94 ref={ref} 95 style={{ 96 display: 'flex', 97 flex: 1, 98 cursor: 'default', 99 backgroundColor: t.palette.black, 100 backgroundImage: `url(${embed.thumbnail})`, 101 backgroundSize: 'contain', 102 backgroundPosition: 'center', 103 backgroundRepeat: 'no-repeat', 104 }} 105 onClick={evt => evt.stopPropagation()}> 106 <ErrorBoundary renderError={renderError} key={key}> 107 <OnlyNearScreen> 108 <VideoEmbedInnerWeb 109 embed={embed} 110 active={active} 111 setActive={setActive} 112 onScreen={onScreen} 113 lastKnownTime={lastKnownTime} 114 /> 115 </OnlyNearScreen> 116 </ErrorBoundary> 117 </div> 118 ) 119 120 return ( 121 <View style={[a.pt_xs]}> 122 <ViewportObserver 123 sendPosition={isGif ? noop : sendPosition} 124 isAnyViewActive={currentActiveView !== null}> 125 <ConstrainedImage 126 fullBleed 127 aspectRatio={constrained || 1} 128 // slightly smaller max height than images 129 // images use 16 / 9, for reference 130 minMobileAspectRatio={14 / 9}> 131 {contents} 132 <MediaInsetBorder /> 133 </ConstrainedImage> 134 </ViewportObserver> 135 </View> 136 ) 137} 138 139const NearScreenContext = createContext(false) 140NearScreenContext.displayName = 'VideoNearScreenContext' 141 142/** 143 * Renders a 100vh tall div and watches it with an IntersectionObserver to 144 * send the position of the div when it's near the screen. 145 * 146 * IMPORTANT: ViewportObserver _must_ not be within a `overflow: hidden` container. 147 */ 148function ViewportObserver({ 149 children, 150 sendPosition, 151 isAnyViewActive, 152}: { 153 children: React.ReactNode 154 sendPosition: (position: number) => void 155 isAnyViewActive: boolean 156}) { 157 const ref = useRef<HTMLDivElement>(null) 158 const [nearScreen, setNearScreen] = useState(false) 159 const [isFullscreen] = useFullscreen() 160 const isWithinMessage = useIsWithinMessage() 161 162 // Send position when scrolling. This is done with an IntersectionObserver 163 // observing a div of 100vh height 164 useEffect(() => { 165 if (!ref.current) return 166 if (isFullscreen && !IS_WEB_FIREFOX) return 167 const observer = new IntersectionObserver( 168 entries => { 169 const entry = entries[0] 170 if (!entry) return 171 const position = 172 entry.boundingClientRect.y + entry.boundingClientRect.height / 2 173 sendPosition(position) 174 setNearScreen(entry.isIntersecting) 175 }, 176 {threshold: Array.from({length: 101}, (_, i) => i / 100)}, 177 ) 178 observer.observe(ref.current) 179 return () => observer.disconnect() 180 }, [sendPosition, isFullscreen]) 181 182 // In case scrolling hasn't started yet, send up the position 183 useEffect(() => { 184 if (ref.current && !isAnyViewActive) { 185 const rect = ref.current.getBoundingClientRect() 186 const position = rect.y + rect.height / 2 187 sendPosition(position) 188 } 189 }, [isAnyViewActive, sendPosition]) 190 191 return ( 192 <View style={[a.flex_1, a.flex_row]}> 193 <NearScreenContext.Provider value={nearScreen}> 194 {children} 195 </NearScreenContext.Provider> 196 <div 197 ref={ref} 198 style={{ 199 // Don't escape bounds when in a message 200 ...(isWithinMessage 201 ? {top: 0, height: '100%'} 202 : {top: 'calc(50% - 50vh)', height: '100vh'}), 203 position: 'absolute', 204 left: '50%', 205 width: 1, 206 pointerEvents: 'none', 207 }} 208 /> 209 </View> 210 ) 211} 212 213/** 214 * Awkward data flow here, but we need to hide the video when it's not near the screen. 215 * But also, ViewportObserver _must_ not be within a `overflow: hidden` container. 216 * So we put it at the top level of the component tree here, then hide the children of 217 * the auto-resizing container. 218 */ 219export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => { 220 const nearScreen = useContext(NearScreenContext) 221 222 return nearScreen ? children : null 223} 224 225function VideoError({error, retry}: {error: unknown; retry: () => void}) { 226 const {_} = useLingui() 227 228 let showRetryButton = true 229 let text = null 230 231 if (error instanceof VideoNotFoundError) { 232 text = _(msg`Video not found.`) 233 } else if (error instanceof HLSUnsupportedError) { 234 showRetryButton = false 235 text = _( 236 msg`Your browser does not support the video format. Please try a different browser.`, 237 ) 238 } else { 239 text = _(msg`An error occurred while loading the video. Please try again.`) 240 } 241 242 return ( 243 <VideoFallback.Container> 244 <VideoFallback.Text>{text}</VideoFallback.Text> 245 {showRetryButton && <VideoFallback.RetryButton onPress={retry} />} 246 </VideoFallback.Container> 247 ) 248}