forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}