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