mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at utm-source 281 lines 8.0 kB view raw
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})