Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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}