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