forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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 {useExternalEmbedsPrefs} from '#/state/preferences'
30import {EventStopper} from '#/view/com/util/EventStopper'
31import {atoms as a, useTheme} from '#/alf'
32import {useDialogControl} from '#/components/Dialog'
33import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
34import {Fill} from '#/components/Fill'
35import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
36import {IS_NATIVE} from '#/env'
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 = IS_NATIVE // 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 loading="lazy"
230 />
231 <Fill
232 style={[
233 t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
234 {
235 opacity: 0.3,
236 },
237 ]}
238 />
239 </>
240 ) : (
241 <Fill
242 style={[
243 {
244 backgroundColor:
245 t.name === 'light' ? t.palette.contrast_975 : 'black',
246 opacity: 0.3,
247 },
248 ]}
249 />
250 )}
251 <PlaceholderOverlay
252 isLoading={isLoading}
253 isPlayerActive={isPlayerActive}
254 onPress={onPlayPress}
255 />
256 <Player
257 isPlayerActive={isPlayerActive}
258 params={params}
259 onLoad={onLoad}
260 />
261 </Animated.View>
262 </>
263 )
264}
265
266const styles = StyleSheet.create({
267 overlayContainer: {
268 flex: 1,
269 justifyContent: 'center',
270 alignItems: 'center',
271 },
272 overlayLayer: {
273 zIndex: 2,
274 },
275 playerLayer: {
276 zIndex: 3,
277 },
278 webview: {
279 backgroundColor: 'transparent',
280 },
281 gifContainer: {
282 width: '100%',
283 overflow: 'hidden',
284 },
285})