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