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