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