diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx new file mode 100644 index 0000000000..8a12f0374d --- /dev/null +++ b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx @@ -0,0 +1,147 @@ +import React from 'react' +import {ActivityIndicator, GestureResponderEvent, Pressable} from 'react-native' +import {Image} from 'expo-image' +import {AppBskyEmbedExternal} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {EmbedPlayerParams} from '#/lib/strings/embed-player' +import {isIOS, isNative, isWeb} from '#/platform/detection' +import {useExternalEmbedsPrefs} from '#/state/preferences' +import {atoms as a, useTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent' +import {Fill} from '#/components/Fill' +import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' + +export function ExternalGif({ + link, + params, +}: { + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams +}) { + const t = useTheme() + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {_} = useLingui() + const consentDialogControl = useDialogControl() + + // Tracking if the placer has been activated + const [isPlayerActive, setIsPlayerActive] = React.useState(false) + // Tracking whether the gif has been loaded yet + const [isPrefetched, setIsPrefetched] = React.useState(false) + // Tracking whether the image is animating + const [isAnimating, setIsAnimating] = React.useState(true) + + // Used for controlling animation + const imageRef = React.useRef(null) + + const load = React.useCallback(() => { + setIsPlayerActive(true) + Image.prefetch(params.playerUri).then(() => { + // Replace the image once it's fetched + setIsPrefetched(true) + }) + }, [params.playerUri]) + + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Don't propagate on web + event.preventDefault() + + // Show consent if this is the first load + if (externalEmbedsPrefs?.[params.source] === undefined) { + consentDialogControl.open() + return + } + // If the player isn't active, we want to activate it and prefetch the gif + if (!isPlayerActive) { + load() + return + } + // Control animation on native + setIsAnimating(prev => { + if (prev) { + if (isNative) { + imageRef.current?.stopAnimating() + } + return false + } else { + if (isNative) { + imageRef.current?.startAnimating() + } + return true + } + }) + }, + [ + consentDialogControl, + externalEmbedsPrefs, + isPlayerActive, + load, + params.source, + ], + ) + + return ( + <> + + + + + + {(!isPrefetched || !isAnimating) && ( + + + + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active + + ) : ( + // Activity indicator while gif loads + + )} + + )} + + + ) +} diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx new file mode 100644 index 0000000000..7f6d533408 --- /dev/null +++ b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx @@ -0,0 +1,281 @@ +import React from 'react' +import { + ActivityIndicator, + GestureResponderEvent, + Pressable, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native' +import Animated, { + measure, + runOnJS, + useAnimatedRef, + useFrameCallback, +} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {WebView} from 'react-native-webview' +import {Image} from 'expo-image' +import {AppBskyEmbedExternal} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {NavigationProp} from '#/lib/routes/types' +import {EmbedPlayerParams, getPlayerAspect} from '#/lib/strings/embed-player' +import {isNative} from '#/platform/detection' +import {useExternalEmbedsPrefs} from '#/state/preferences' +import {EventStopper} from '#/view/com/util/EventStopper' +import {atoms as a, useTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent' +import {Fill} from '#/components/Fill' +import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' + +interface ShouldStartLoadRequest { + url: string +} + +// This renders the overlay when the player is either inactive or loading as a separate layer +function PlaceholderOverlay({ + isLoading, + isPlayerActive, + onPress, +}: { + isLoading: boolean + isPlayerActive: boolean + onPress: (event: GestureResponderEvent) => void +}) { + const {_} = useLingui() + + // If the player is active and not loading, we don't want to show the overlay. + if (isPlayerActive && !isLoading) return null + + return ( + + + {!isPlayerActive ? ( + + ) : ( + + )} + + + ) +} + +// This renders the webview/youtube player as a separate layer +function Player({ + params, + onLoad, + isPlayerActive, +}: { + isPlayerActive: boolean + params: EmbedPlayerParams + onLoad: () => void +}) { + // ensures we only load what's requested + // when it's a youtube video, we need to allow both bsky.app and youtube.com + const onShouldStartLoadWithRequest = React.useCallback( + (event: ShouldStartLoadRequest) => + event.url === params.playerUri || + (params.source.startsWith('youtube') && + event.url.includes('www.youtube.com')), + [params.playerUri, params.source], + ) + + // Don't show the player until it is active + if (!isPlayerActive) return null + + return ( + + + + ) +} + +// This renders the player area and handles the logic for when to show the player and when to show the overlay +export function ExternalPlayer({ + link, + params, +}: { + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams +}) { + const t = useTheme() + const navigation = useNavigation() + const insets = useSafeAreaInsets() + const windowDims = useWindowDimensions() + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const consentDialogControl = useDialogControl() + + const [isPlayerActive, setPlayerActive] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(true) + + const aspect = React.useMemo(() => { + return getPlayerAspect({ + type: params.type, + width: windowDims.width, + hasThumb: !!link.thumb, + }) + }, [params.type, windowDims.width, link.thumb]) + + const viewRef = useAnimatedRef() + const frameCallback = useFrameCallback(() => { + const measurement = measure(viewRef) + if (!measurement) return + + const {height: winHeight, width: winWidth} = windowDims + + // Get the proper screen height depending on what is going on + const realWinHeight = isNative // If it is native, we always want the larger number + ? winHeight > winWidth + ? winHeight + : winWidth + : winHeight // On web, we always want the actual screen height + + const top = measurement.pageY + const bot = measurement.pageY + measurement.height + + // We can use the same logic on all platforms against the screenHeight that we get above + const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top + + if (!isVisible) { + runOnJS(setPlayerActive)(false) + } + }, false) // False here disables autostarting the callback + + // watch for leaving the viewport due to scrolling + React.useEffect(() => { + // We don't want to do anything if the player isn't active + if (!isPlayerActive) return + + // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will + // continue playing. We need to watch for the blur event + const unsubscribe = navigation.addListener('blur', () => { + setPlayerActive(false) + }) + + // Start watching for changes + frameCallback.setActive(true) + + return () => { + unsubscribe() + frameCallback.setActive(false) + } + }, [navigation, isPlayerActive, frameCallback]) + + const onLoad = React.useCallback(() => { + setIsLoading(false) + }, []) + + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Prevent this from propagating upward on web + event.preventDefault() + + if (externalEmbedsPrefs?.[params.source] === undefined) { + consentDialogControl.open() + return + } + + setPlayerActive(true) + }, + [externalEmbedsPrefs, consentDialogControl, params.source], + ) + + const onAcceptConsent = React.useCallback(() => { + setPlayerActive(true) + }, []) + + return ( + <> + + + + {link.thumb && (!isPlayerActive || isLoading) ? ( + <> + + + + ) : ( + + )} + + + + + ) +} + +const styles = StyleSheet.create({ + overlayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + overlayLayer: { + zIndex: 2, + }, + playerLayer: { + zIndex: 3, + }, + webview: { + backgroundColor: 'transparent', + }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, +}) diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx index 535cfca3c0..6d6a664565 100644 --- a/src/components/Post/Embed/ExternalEmbed/index.tsx +++ b/src/components/Post/Embed/ExternalEmbed/index.tsx @@ -11,14 +11,14 @@ import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player' import {toNiceDomain} from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' import {useExternalEmbedsPrefs} from '#/state/preferences' -import {ExternalGifEmbed} from '#/view/com/util/post-embeds/ExternalGifEmbed' -import {ExternalPlayer} from '#/view/com/util/post-embeds/ExternalPlayerEmbed' import {GifEmbed} from '#/view/com/util/post-embeds/GifEmbed' import {atoms as a, useTheme} from '#/alf' import {Divider} from '#/components/Divider' import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' import {Link} from '#/components/Link' import {Text} from '#/components/Typography' +import {ExternalGif} from './ExternalGif' +import {ExternalPlayer} from './ExternalPlayer' export const ExternalEmbed = ({ link, @@ -98,7 +98,7 @@ export const ExternalEmbed = ({ ) : undefined} {embedPlayerParams?.isGif ? ( - + ) : embedPlayerParams ? ( ) : undefined}