Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import React from 'react'
2import {
3 ActivityIndicator,
4 type GestureResponderEvent,
5 Pressable,
6 StyleSheet,
7 useWindowDimensions,
8 View,
9} from 'react-native'
10import Animated, {
11 measure,
12 runOnJS,
13 useAnimatedRef,
14 useFrameCallback,
15} from 'react-native-reanimated'
16import {useSafeAreaInsets} from 'react-native-safe-area-context'
17import {WebView} from 'react-native-webview'
18import {Image} from 'expo-image'
19import {type AppBskyEmbedExternal} from '@atproto/api'
20import {msg} from '@lingui/core/macro'
21import {useLingui} from '@lingui/react'
22import {useNavigation} from '@react-navigation/native'
23
24import {type NavigationProp} from '#/lib/routes/types'
25import {
26 type EmbedPlayerParams,
27 getPlayerAspect,
28} from '#/lib/strings/embed-player'
29import {useExternalEmbedsPrefs} from '#/state/preferences'
30import {useHighQualityImages} from '#/state/preferences/high-quality-images'
31import {
32 applyImageTransforms,
33 useImageCdnHost,
34} from '#/state/preferences/image-cdn-host'
35import {EventStopper} from '#/view/com/util/EventStopper'
36import {atoms as a, useTheme} from '#/alf'
37import {useDialogControl} from '#/components/Dialog'
38import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
39import {Fill} from '#/components/Fill'
40import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
41import {IS_NATIVE} from '#/env'
42
43interface ShouldStartLoadRequest {
44 url: string
45}
46
47// This renders the overlay when the player is either inactive or loading as a separate layer
48function PlaceholderOverlay({
49 isLoading,
50 isPlayerActive,
51 onPress,
52}: {
53 isLoading: boolean
54 isPlayerActive: boolean
55 onPress: (event: GestureResponderEvent) => void
56}) {
57 const {_} = useLingui()
58
59 // If the player is active and not loading, we don't want to show the overlay.
60 if (isPlayerActive && !isLoading) return null
61
62 return (
63 <View style={[a.absolute, a.inset_0, styles.overlayLayer]}>
64 <Pressable
65 accessibilityRole="button"
66 accessibilityLabel={_(msg`Play Video`)}
67 accessibilityHint={_(msg`Plays the video`)}
68 onPress={onPress}
69 style={[styles.overlayContainer]}>
70 {!isPlayerActive ? (
71 <PlayButtonIcon />
72 ) : (
73 <ActivityIndicator size="large" color="white" />
74 )}
75 </Pressable>
76 </View>
77 )
78}
79
80// This renders the webview/youtube player as a separate layer
81function Player({
82 params,
83 onLoad,
84 isPlayerActive,
85}: {
86 isPlayerActive: boolean
87 params: EmbedPlayerParams
88 onLoad: () => void
89}) {
90 // ensures we only load what's requested
91 // when it's a youtube video, we need to allow both bsky.app and youtube.com
92 const onShouldStartLoadWithRequest = React.useCallback(
93 (event: ShouldStartLoadRequest) =>
94 event.url === params.playerUri ||
95 (params.source.startsWith('youtube') &&
96 event.url.includes('www.youtube.com')),
97 [params.playerUri, params.source],
98 )
99
100 // Don't show the player until it is active
101 if (!isPlayerActive) return null
102
103 return (
104 <EventStopper style={[a.absolute, a.inset_0, styles.playerLayer]}>
105 <WebView
106 javaScriptEnabled={true}
107 onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
108 mediaPlaybackRequiresUserAction={false}
109 allowsInlineMediaPlayback
110 bounces={false}
111 allowsFullscreenVideo
112 nestedScrollEnabled
113 source={{uri: params.playerUri}}
114 onLoad={onLoad}
115 style={styles.webview}
116 setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
117 />
118 </EventStopper>
119 )
120}
121
122// This renders the player area and handles the logic for when to show the player and when to show the overlay
123export function ExternalPlayer({
124 link,
125 params,
126}: {
127 link: AppBskyEmbedExternal.ViewExternal
128 params: EmbedPlayerParams
129}) {
130 const t = useTheme()
131 const navigation = useNavigation<NavigationProp>()
132 const insets = useSafeAreaInsets()
133 const windowDims = useWindowDimensions()
134 const externalEmbedsPrefs = useExternalEmbedsPrefs()
135 const consentDialogControl = useDialogControl()
136 const highQualityImages = useHighQualityImages()
137 const imageCdnHost = useImageCdnHost()
138
139 const [isPlayerActive, setPlayerActive] = React.useState(false)
140 const [isLoading, setIsLoading] = React.useState(true)
141
142 const aspect = React.useMemo(() => {
143 return getPlayerAspect({
144 type: params.type,
145 width: windowDims.width,
146 hasThumb: !!link.thumb,
147 })
148 }, [params.type, windowDims.width, link.thumb])
149
150 const viewRef = useAnimatedRef()
151 const frameCallback = useFrameCallback(() => {
152 const measurement = measure(viewRef)
153 if (!measurement) return
154
155 const {height: winHeight, width: winWidth} = windowDims
156
157 // Get the proper screen height depending on what is going on
158 const realWinHeight = IS_NATIVE // If it is native, we always want the larger number
159 ? winHeight > winWidth
160 ? winHeight
161 : winWidth
162 : winHeight // On web, we always want the actual screen height
163
164 const top = measurement.pageY
165 const bot = measurement.pageY + measurement.height
166
167 // We can use the same logic on all platforms against the screenHeight that we get above
168 const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top
169
170 if (!isVisible) {
171 runOnJS(setPlayerActive)(false)
172 }
173 }, false) // False here disables autostarting the callback
174
175 // watch for leaving the viewport due to scrolling
176 React.useEffect(() => {
177 // We don't want to do anything if the player isn't active
178 if (!isPlayerActive) return
179
180 // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
181 // continue playing. We need to watch for the blur event
182 const unsubscribe = navigation.addListener('blur', () => {
183 setPlayerActive(false)
184 })
185
186 // Start watching for changes
187 frameCallback.setActive(true)
188
189 return () => {
190 unsubscribe()
191 frameCallback.setActive(false)
192 }
193 }, [navigation, isPlayerActive, frameCallback])
194
195 const onLoad = React.useCallback(() => {
196 setIsLoading(false)
197 }, [])
198
199 const onPlayPress = React.useCallback(
200 (event: GestureResponderEvent) => {
201 // Prevent this from propagating upward on web
202 event.preventDefault()
203
204 if (externalEmbedsPrefs?.[params.source] === undefined) {
205 consentDialogControl.open()
206 return
207 }
208
209 setPlayerActive(true)
210 },
211 [externalEmbedsPrefs, consentDialogControl, params.source],
212 )
213
214 const onAcceptConsent = React.useCallback(() => {
215 setPlayerActive(true)
216 }, [])
217
218 return (
219 <>
220 <EmbedConsentDialog
221 control={consentDialogControl}
222 source={params.source}
223 onAccept={onAcceptConsent}
224 />
225
226 <Animated.View
227 ref={viewRef}
228 collapsable={false}
229 style={[aspect, a.overflow_hidden]}>
230 {link.thumb && (!isPlayerActive || isLoading) ? (
231 <>
232 <Image
233 style={[a.flex_1]}
234 source={{
235 uri: applyImageTransforms(link.thumb, {
236 imageCdnHost,
237 highQualityImages,
238 }),
239 }}
240 accessibilityIgnoresInvertColors
241 loading="lazy"
242 />
243 <Fill
244 style={[
245 t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
246 {
247 opacity: 0.3,
248 },
249 ]}
250 />
251 </>
252 ) : (
253 <Fill
254 style={[
255 {
256 backgroundColor:
257 t.name === 'light' ? t.palette.contrast_975 : 'black',
258 opacity: 0.3,
259 },
260 ]}
261 />
262 )}
263 <PlaceholderOverlay
264 isLoading={isLoading}
265 isPlayerActive={isPlayerActive}
266 onPress={onPlayPress}
267 />
268 <Player
269 isPlayerActive={isPlayerActive}
270 params={params}
271 onLoad={onLoad}
272 />
273 </Animated.View>
274 </>
275 )
276}
277
278const styles = StyleSheet.create({
279 overlayContainer: {
280 flex: 1,
281 justifyContent: 'center',
282 alignItems: 'center',
283 },
284 overlayLayer: {
285 zIndex: 2,
286 },
287 playerLayer: {
288 zIndex: 3,
289 },
290 webview: {
291 backgroundColor: 'transparent',
292 },
293 gifContainer: {
294 width: '100%',
295 overflow: 'hidden',
296 },
297})