diff options
author | Eric Bailey <git@esb.lol> | 2025-06-13 12:05:41 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-06-13 12:05:41 -0500 |
commit | 45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 (patch) | |
tree | a2fd6917867f18fe334b54dd3289775c2930bc85 /src/components/Post | |
parent | ba0f5a9bdef5bd0447ded23cab1af222b65511cc (diff) | |
download | voidsky-45f0f7eefecae1922c2f30d4e7760d2b93b1ae56.tar.zst |
Port post embeds to new arch (#7408)
* Direct port of embeds to new arch (cherry picked from commit cc3fa1f6cea396dd9222486c633a508bfee1ecd6) * Re-org * Split out ListEmbed and FeedEmbed * Split out ImageEmbed * DRY up a bit * Port over ExternalLinkEmbed * Port over Player and Gif embeds * Migrate ComposerReplyTo * Replace other usages of old post-embeds * Migrate view contexts * Copy pasta VideoEmbed * Copy pasta GifEmbed * Swap in new file location * Clean up * Fix up native * Add back in correct moderation on List and Feed embeds * Format * Prettier * delete old video utils * move bandwidth-estimate.ts * Remove log * Add LazyQuoteEmbed for composer use * Clean up unused things * Remove remaining items * Prettier * Fix imports * Handle nested quotes same as prod * Add back silenced error handling * Fix lint --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/components/Post')
28 files changed, 3715 insertions, 0 deletions
diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx new file mode 100644 index 000000000..8a12f0374 --- /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<Image>(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 ( + <> + <EmbedConsentDialog + control={consentDialogControl} + source={params.source} + onAccept={load} + /> + + <Pressable + style={[ + {height: 300}, + a.w_full, + a.overflow_hidden, + { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + ]} + onPress={onPlayPress} + accessibilityRole="button" + accessibilityHint={_(msg`Plays the GIF`)} + accessibilityLabel={_(msg`Play ${link.title}`)}> + <Image + source={{ + uri: + !isPrefetched || (isWeb && !isAnimating) + ? link.thumb + : params.playerUri, + }} // Web uses the thumb to control playback + style={{flex: 1}} + ref={imageRef} + autoplay={isAnimating} + contentFit="contain" + accessibilityIgnoresInvertColors + accessibilityLabel={link.title} + accessibilityHint={link.title} + cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios + /> + + {(!isPrefetched || !isAnimating) && ( + <Fill style={[a.align_center, a.justify_center]}> + <Fill + style={[ + t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, + { + opacity: 0.3, + }, + ]} + /> + + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active + <PlayButtonIcon /> + ) : ( + // Activity indicator while gif loads + <ActivityIndicator size="large" color="white" /> + )} + </Fill> + )} + </Pressable> + </> + ) +} diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx new file mode 100644 index 000000000..7f6d53340 --- /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 ( + <View style={[a.absolute, a.inset_0, styles.overlayLayer]}> + <Pressable + accessibilityRole="button" + accessibilityLabel={_(msg`Play Video`)} + accessibilityHint={_(msg`Plays the video`)} + onPress={onPress} + style={[styles.overlayContainer]}> + {!isPlayerActive ? ( + <PlayButtonIcon /> + ) : ( + <ActivityIndicator size="large" color="white" /> + )} + </Pressable> + </View> + ) +} + +// 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 ( + <EventStopper style={[a.absolute, a.inset_0, styles.playerLayer]}> + <WebView + javaScriptEnabled={true} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + mediaPlaybackRequiresUserAction={false} + allowsInlineMediaPlayback + bounces={false} + allowsFullscreenVideo + nestedScrollEnabled + source={{uri: params.playerUri}} + onLoad={onLoad} + style={styles.webview} + setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) + /> + </EventStopper> + ) +} + +// 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<NavigationProp>() + 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 ( + <> + <EmbedConsentDialog + control={consentDialogControl} + source={params.source} + onAccept={onAcceptConsent} + /> + + <Animated.View + ref={viewRef} + collapsable={false} + style={[aspect, a.overflow_hidden]}> + {link.thumb && (!isPlayerActive || isLoading) ? ( + <> + <Image + style={[a.flex_1]} + source={{uri: link.thumb}} + accessibilityIgnoresInvertColors + /> + <Fill + style={[ + t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, + { + opacity: 0.3, + }, + ]} + /> + </> + ) : ( + <Fill + style={[ + { + backgroundColor: + t.name === 'light' ? t.palette.contrast_975 : 'black', + opacity: 0.3, + }, + ]} + /> + )} + <PlaceholderOverlay + isLoading={isLoading} + isPlayerActive={isPlayerActive} + onPress={onPlayPress} + /> + <Player + isPlayerActive={isPlayerActive} + params={params} + onLoad={onLoad} + /> + </Animated.View> + </> + ) +} + +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/Gif.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx new file mode 100644 index 000000000..a839294f1 --- /dev/null +++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx @@ -0,0 +1,224 @@ +import React from 'react' +import { + Pressable, + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_20} from '#/lib/constants' +import {EmbedPlayerParams} from '#/lib/strings/embed-player' +import {isWeb} from '#/platform/detection' +import {useAutoplayDisabled} from '#/state/preferences' +import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {atoms as a, useTheme} from '#/alf' +import {Fill} from '#/components/Fill' +import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' +import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' +import {GifView} from '../../../../../modules/expo-bluesky-gif-view' +import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' + +function PlaybackControls({ + onPress, + isPlaying, + isLoaded, +}: { + onPress: () => void + isPlaying: boolean + isLoaded: boolean +}) { + const {_} = useLingui() + const t = useTheme() + + return ( + <Pressable + accessibilityRole="button" + accessibilityHint={_(msg`Plays or pauses the GIF`)} + accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)} + style={[ + a.absolute, + a.align_center, + a.justify_center, + !isLoaded && a.border, + t.atoms.border_contrast_medium, + a.inset_0, + a.w_full, + a.h_full, + { + zIndex: 2, + backgroundColor: !isLoaded + ? t.atoms.bg_contrast_25.backgroundColor + : undefined, + }, + ]} + onPress={onPress}> + {!isLoaded ? ( + <View> + <View style={[a.align_center, a.justify_center]}> + <Loader size="xl" /> + </View> + </View> + ) : !isPlaying ? ( + <PlayButtonIcon /> + ) : undefined} + </Pressable> + ) +} + +export function GifEmbed({ + params, + thumb, + altText, + isPreferredAltText, + hideAlt, + style = {width: '100%'}, +}: { + params: EmbedPlayerParams + thumb: string | undefined + altText: string + isPreferredAltText: boolean + hideAlt?: boolean + style?: StyleProp<ViewStyle> +}) { + const t = useTheme() + const {_} = useLingui() + const autoplayDisabled = useAutoplayDisabled() + + const playerRef = React.useRef<GifView>(null) + + const [playerState, setPlayerState] = React.useState<{ + isPlaying: boolean + isLoaded: boolean + }>({ + isPlaying: !autoplayDisabled, + isLoaded: false, + }) + + const onPlayerStateChange = React.useCallback( + (e: GifViewStateChangeEvent) => { + setPlayerState(e.nativeEvent) + }, + [], + ) + + const onPress = React.useCallback(() => { + playerRef.current?.toggleAsync() + }, []) + + return ( + <View + style={[ + a.rounded_md, + a.overflow_hidden, + a.border, + t.atoms.border_contrast_low, + {aspectRatio: params.dimensions!.width / params.dimensions!.height}, + style, + ]}> + <View + style={[ + a.absolute, + /* + * Aspect ratio was being clipped weirdly on web -esb + */ + { + top: -2, + bottom: -2, + left: -2, + right: -2, + }, + ]}> + <PlaybackControls + onPress={onPress} + isPlaying={playerState.isPlaying} + isLoaded={playerState.isLoaded} + /> + <GifView + source={params.playerUri} + placeholderSource={thumb} + style={[a.flex_1]} + autoplay={!autoplayDisabled} + onPlayerStateChange={onPlayerStateChange} + ref={playerRef} + accessibilityHint={_(msg`Animated GIF`)} + accessibilityLabel={altText} + /> + {!playerState.isPlaying && ( + <Fill + style={[ + t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, + { + opacity: 0.3, + }, + ]} + /> + )} + {!hideAlt && isPreferredAltText && <AltText text={altText} />} + </View> + </View> + ) +} + +function AltText({text}: {text: string}) { + const control = Prompt.usePromptControl() + const largeAltBadge = useLargeAltBadgeEnabled() + + const {_} = useLingui() + return ( + <> + <TouchableOpacity + testID="altTextButton" + accessibilityRole="button" + accessibilityLabel={_(msg`Show alt text`)} + accessibilityHint="" + hitSlop={HITSLOP_20} + onPress={control.open} + style={styles.altContainer}> + <Text + style={[styles.alt, largeAltBadge && a.text_xs]} + accessible={false}> + <Trans>ALT</Trans> + </Text> + </TouchableOpacity> + <Prompt.Outer control={control}> + <Prompt.TitleText> + <Trans>Alt Text</Trans> + </Prompt.TitleText> + <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> + <Prompt.Actions> + <Prompt.Action + onPress={() => control.close()} + cta={_(msg`Close`)} + color="secondary" + /> + </Prompt.Actions> + </Prompt.Outer> + </> + ) +} + +const styles = StyleSheet.create({ + altContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: isWeb ? 8 : 6, + paddingVertical: isWeb ? 6 : 3, + position: 'absolute', + // Related to margin/gap hack. This keeps the alt label in the same position + // on all platforms + right: isWeb ? 8 : 5, + bottom: isWeb ? 8 : 5, + zIndex: 2, + }, + alt: { + color: 'white', + fontSize: isWeb ? 10 : 7, + fontWeight: '600', + }, +}) diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx new file mode 100644 index 000000000..714eaecd6 --- /dev/null +++ b/src/components/Post/Embed/ExternalEmbed/index.tsx @@ -0,0 +1,182 @@ +import React, {useCallback} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {Image} from 'expo-image' +import {type AppBskyEmbedExternal} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {parseAltFromGIFDescription} from '#/lib/gif-alt-text' +import {useHaptics} from '#/lib/haptics' +import {shareUrl} from '#/lib/sharing' +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 {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' +import {GifEmbed} from './Gif' + +export const ExternalEmbed = ({ + link, + onOpen, + style, + hideAlt, +}: { + link: AppBskyEmbedExternal.ViewExternal + onOpen?: () => void + style?: StyleProp<ViewStyle> + hideAlt?: boolean +}) => { + const {_} = useLingui() + const t = useTheme() + const playHaptic = useHaptics() + const externalEmbedPrefs = useExternalEmbedsPrefs() + const niceUrl = toNiceDomain(link.uri) + const imageUri = link.thumb + const embedPlayerParams = React.useMemo(() => { + const params = parseEmbedPlayerFromUrl(link.uri) + + if (params && externalEmbedPrefs?.[params.source] !== 'hide') { + return params + } + }, [link.uri, externalEmbedPrefs]) + const hasMedia = Boolean(imageUri || embedPlayerParams) + + const onPress = useCallback(() => { + playHaptic('Light') + onOpen?.() + }, [playHaptic, onOpen]) + + const onShareExternal = useCallback(() => { + if (link.uri && isNative) { + playHaptic('Heavy') + shareUrl(link.uri) + } + }, [link.uri, playHaptic]) + + if (embedPlayerParams?.source === 'tenor') { + const parsedAlt = parseAltFromGIFDescription(link.description) + return ( + <View style={style}> + <GifEmbed + params={embedPlayerParams} + thumb={link.thumb} + altText={parsedAlt.alt} + isPreferredAltText={parsedAlt.isPreferred} + hideAlt={hideAlt} + /> + </View> + ) + } + + return ( + <Link + label={link.title || _(msg`Open link to ${niceUrl}`)} + to={link.uri} + shouldProxy={true} + onPress={onPress} + onLongPress={onShareExternal}> + {({hovered}) => ( + <View + style={[ + a.transition_color, + a.flex_col, + a.rounded_md, + a.overflow_hidden, + a.w_full, + a.border, + style, + hovered + ? t.atoms.border_contrast_high + : t.atoms.border_contrast_low, + ]}> + {imageUri && !embedPlayerParams ? ( + <Image + style={{ + aspectRatio: 1.91, + }} + source={{uri: imageUri}} + accessibilityIgnoresInvertColors + /> + ) : undefined} + + {embedPlayerParams?.isGif ? ( + <ExternalGif link={link} params={embedPlayerParams} /> + ) : embedPlayerParams ? ( + <ExternalPlayer link={link} params={embedPlayerParams} /> + ) : undefined} + + <View + style={[ + a.flex_1, + a.pt_sm, + {gap: 3}, + hasMedia && a.border_t, + hovered + ? t.atoms.border_contrast_high + : t.atoms.border_contrast_low, + ]}> + <View style={[{gap: 3}, a.pb_xs, a.px_md]}> + {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && ( + <Text + emoji + numberOfLines={3} + style={[a.text_md, a.font_bold, a.leading_snug]}> + {link.title || link.uri} + </Text> + )} + {link.description ? ( + <Text + emoji + numberOfLines={link.thumb ? 2 : 4} + style={[a.text_sm, a.leading_snug]}> + {link.description} + </Text> + ) : undefined} + </View> + <View style={[a.px_md]}> + <Divider /> + <View + style={[ + a.flex_row, + a.align_center, + a.gap_2xs, + a.pb_sm, + { + paddingTop: 6, // off menu + }, + ]}> + <Globe + size="xs" + style={[ + a.transition_color, + hovered + ? t.atoms.text_contrast_medium + : t.atoms.text_contrast_low, + ]} + /> + <Text + numberOfLines={1} + style={[ + a.transition_color, + a.text_xs, + a.leading_snug, + hovered + ? t.atoms.text_contrast_high + : t.atoms.text_contrast_medium, + ]}> + {toNiceDomain(link.uri)} + </Text> + </View> + </View> + </View> + </View> + )} + </Link> + ) +} diff --git a/src/components/Post/Embed/FeedEmbed.tsx b/src/components/Post/Embed/FeedEmbed.tsx new file mode 100644 index 000000000..fad4cd4d8 --- /dev/null +++ b/src/components/Post/Embed/FeedEmbed.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import {StyleSheet} from 'react-native' +import {moderateFeedGenerator} from '@atproto/api' + +import {usePalette} from '#/lib/hooks/usePalette' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' +import {ContentHider} from '#/components/moderation/ContentHider' +import {type EmbedType} from '#/types/bsky/post' +import {type CommonProps} from './types' + +export function FeedEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'feed'> +}) { + const pal = usePalette('default') + return ( + <FeedSourceCard + feedUri={embed.view.uri} + style={[pal.view, pal.border, styles.customFeedOuter]} + showLikes + /> + ) +} + +export function ModeratedFeedEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'feed'> +}) { + const moderationOpts = useModerationOpts() + const moderation = React.useMemo(() => { + return moderationOpts + ? moderateFeedGenerator(embed.view, moderationOpts) + : undefined + }, [embed.view, moderationOpts]) + return ( + <ContentHider modui={moderation?.ui('contentList')}> + <FeedEmbed embed={embed} /> + </ContentHider> + ) +} + +const styles = StyleSheet.create({ + customFeedOuter: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + }, +}) diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx new file mode 100644 index 000000000..030d237a0 --- /dev/null +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -0,0 +1,106 @@ +import {InteractionManager, View} from 'react-native' +import { + type AnimatedRef, + measure, + type MeasuredDimensions, + runOnJS, + runOnUI, +} from 'react-native-reanimated' +import {Image} from 'expo-image' + +import {useLightboxControls} from '#/state/lightbox' +import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' +import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage' +import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid' +import {atoms as a} from '#/alf' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {type EmbedType} from '#/types/bsky/post' +import {type CommonProps} from './types' + +export function ImageEmbed({ + embed, + ...rest +}: CommonProps & { + embed: EmbedType<'images'> +}) { + const {openLightbox} = useLightboxControls() + const {images} = embed.view + + if (images.length > 0) { + const items = images.map(img => ({ + uri: img.fullsize, + thumbUri: img.thumb, + alt: img.alt, + dimensions: img.aspectRatio ?? null, + })) + const _openLightbox = ( + index: number, + thumbRects: (MeasuredDimensions | null)[], + fetchedDims: (Dimensions | null)[], + ) => { + openLightbox({ + images: items.map((item, i) => ({ + ...item, + thumbRect: thumbRects[i] ?? null, + thumbDimensions: fetchedDims[i] ?? null, + type: 'image', + })), + index, + }) + } + const onPress = ( + index: number, + refs: AnimatedRef<any>[], + fetchedDims: (Dimensions | null)[], + ) => { + runOnUI(() => { + 'worklet' + const rects: (MeasuredDimensions | null)[] = [] + for (const r of refs) { + rects.push(measure(r)) + } + runOnJS(_openLightbox)(index, rects, fetchedDims) + })() + } + const onPressIn = (_: number) => { + InteractionManager.runAfterInteractions(() => { + Image.prefetch(items.map(i => i.uri)) + }) + } + + if (images.length === 1) { + const image = images[0] + return ( + <View style={[a.mt_sm, rest.style]}> + <AutoSizedImage + crop={ + rest.viewContext === PostEmbedViewContext.ThreadHighlighted + ? 'none' + : rest.viewContext === + PostEmbedViewContext.FeedEmbedRecordWithMedia + ? 'square' + : 'constrained' + } + image={image} + onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])} + onPressIn={() => onPressIn(0)} + hideBadge={ + rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + </View> + ) + } + + return ( + <View style={[a.mt_sm, rest.style]}> + <ImageLayoutGrid + images={images} + onPress={onPress} + onPressIn={onPressIn} + viewContext={rest.viewContext} + /> + </View> + ) + } +} diff --git a/src/components/Post/Embed/LazyQuoteEmbed.tsx b/src/components/Post/Embed/LazyQuoteEmbed.tsx new file mode 100644 index 000000000..fdc1c6309 --- /dev/null +++ b/src/components/Post/Embed/LazyQuoteEmbed.tsx @@ -0,0 +1,37 @@ +import {useMemo} from 'react' +import {View} from 'react-native' + +import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util' +import {useResolveLinkQuery} from '#/state/queries/resolve-link' +import {atoms as a, useTheme} from '#/alf' +import {QuoteEmbed} from '#/components/Post/Embed' + +export function LazyQuoteEmbed({uri}: {uri: string}) { + const t = useTheme() + const {data} = useResolveLinkQuery(uri) + + const view = useMemo(() => { + if (!data || data.type !== 'record' || data.kind !== 'post') return + return createEmbedViewRecordFromPost(data.view) + }, [data]) + + return view ? ( + <QuoteEmbed + embed={{ + type: 'post', + view, + }} + /> + ) : ( + <View + style={[ + a.w_full, + a.rounded_md, + t.atoms.bg_contrast_25, + { + height: 68, + }, + ]} + /> + ) +} diff --git a/src/components/Post/Embed/ListEmbed.tsx b/src/components/Post/Embed/ListEmbed.tsx new file mode 100644 index 000000000..dc79a7579 --- /dev/null +++ b/src/components/Post/Embed/ListEmbed.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import {View} from 'react-native' +import {moderateUserList} from '@atproto/api' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {atoms as a, useTheme} from '#/alf' +import * as ListCard from '#/components/ListCard' +import {ContentHider} from '#/components/moderation/ContentHider' +import {EmbedType} from '#/types/bsky/post' +import {CommonProps} from './types' + +export function ListEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'list'> +}) { + const t = useTheme() + return ( + <View + style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}> + <ListCard.Default view={embed.view} /> + </View> + ) +} + +export function ModeratedListEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'list'> +}) { + const moderationOpts = useModerationOpts() + const moderation = React.useMemo(() => { + return moderationOpts + ? moderateUserList(embed.view, moderationOpts) + : undefined + }, [embed.view, moderationOpts]) + return ( + <ContentHider modui={moderation?.ui('contentList')}> + <ListEmbed embed={embed} /> + </ContentHider> + ) +} diff --git a/src/components/Post/Embed/PostPlaceholder.tsx b/src/components/Post/Embed/PostPlaceholder.tsx new file mode 100644 index 000000000..840234026 --- /dev/null +++ b/src/components/Post/Embed/PostPlaceholder.tsx @@ -0,0 +1,33 @@ +import {StyleSheet, View} from 'react-native' + +import {usePalette} from '#/lib/hooks/usePalette' +import {InfoCircleIcon} from '#/lib/icons' +import {Text} from '#/view/com/util/text/Text' +import {atoms as a, useTheme} from '#/alf' + +export function PostPlaceholder({children}: {children: React.ReactNode}) { + const t = useTheme() + const pal = usePalette('default') + return ( + <View + style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> + <InfoCircleIcon size={18} style={pal.text} /> + <Text type="lg" style={pal.text}> + {children} + </Text> + </View> + ) +} + +const styles = StyleSheet.create({ + errorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 8, + paddingVertical: 14, + paddingHorizontal: 14, + borderWidth: StyleSheet.hairlineWidth, + }, +}) diff --git a/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx new file mode 100644 index 000000000..a038403b2 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx @@ -0,0 +1,114 @@ +import React, { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react' +import {useWindowDimensions} from 'react-native' + +import {isNative, isWeb} from '#/platform/detection' + +const Context = React.createContext<{ + activeViewId: string | null + setActiveView: (viewId: string) => void + sendViewPosition: (viewId: string, y: number) => void +} | null>(null) + +export function Provider({children}: {children: React.ReactNode}) { + if (!isWeb) { + throw new Error('ActiveVideoWebContext may only be used on web.') + } + + const [activeViewId, setActiveViewId] = useState<string | null>(null) + const activeViewLocationRef = useRef(Infinity) + const {height: windowHeight} = useWindowDimensions() + + // minimising re-renders by using refs + const manuallySetRef = useRef(false) + const activeViewIdRef = useRef(activeViewId) + useEffect(() => { + activeViewIdRef.current = activeViewId + }, [activeViewId]) + + const setActiveView = useCallback( + (viewId: string) => { + setActiveViewId(viewId) + manuallySetRef.current = true + // we don't know the exact position, but it's definitely on screen + // so just guess that it's in the middle. Any value is fine + // so long as it's not offscreen + activeViewLocationRef.current = windowHeight / 2 + }, + [windowHeight], + ) + + const sendViewPosition = useCallback( + (viewId: string, y: number) => { + if (isNative) return + + if (viewId === activeViewIdRef.current) { + activeViewLocationRef.current = y + } else { + if ( + distanceToIdealPosition(y) < + distanceToIdealPosition(activeViewLocationRef.current) + ) { + // if the old view was manually set, only usurp if the old view is offscreen + if ( + manuallySetRef.current && + withinViewport(activeViewLocationRef.current) + ) { + return + } + + setActiveViewId(viewId) + activeViewLocationRef.current = y + manuallySetRef.current = false + } + } + + function distanceToIdealPosition(yPos: number) { + return Math.abs(yPos - windowHeight / 2.5) + } + + function withinViewport(yPos: number) { + return yPos > 0 && yPos < windowHeight + } + }, + [windowHeight], + ) + + const value = useMemo( + () => ({ + activeViewId, + setActiveView, + sendViewPosition, + }), + [activeViewId, setActiveView, sendViewPosition], + ) + + return <Context.Provider value={value}>{children}</Context.Provider> +} + +export function useActiveVideoWeb() { + const context = React.useContext(Context) + if (!context) { + throw new Error( + 'useActiveVideoWeb must be used within a ActiveVideoWebProvider', + ) + } + + const {activeViewId, setActiveView, sendViewPosition} = context + const id = useId() + + return { + active: activeViewId === id, + setActive: () => { + setActiveView(id) + }, + currentActiveView: activeViewId, + sendPosition: (y: number) => sendViewPosition(id, y), + } +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx new file mode 100644 index 000000000..95401309f --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx @@ -0,0 +1,64 @@ +import {StyleProp, ViewStyle} from 'react-native' +import {View} from 'react-native' +import {msg, plural} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +/** + * Absolutely positioned time indicator showing how many seconds are remaining + * Time is in seconds + */ +export function TimeIndicator({ + time, + style, +}: { + time: number + style?: StyleProp<ViewStyle> +}) { + const t = useTheme() + const {_} = useLingui() + + if (isNaN(time)) { + return null + } + + const minutes = Math.floor(time / 60) + const seconds = String(time % 60).padStart(2, '0') + + return ( + <View + pointerEvents="none" + accessibilityLabel={_( + msg`Time remaining: ${plural(Number(time) || 0, { + one: '# second', + other: '# seconds', + })}`, + )} + accessibilityHint="" + style={[ + { + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + left: 6, + bottom: 6, + minHeight: 21, + }, + a.absolute, + a.justify_center, + style, + ]}> + <Text + style={[ + {color: t.palette.white, fontSize: 12, fontVariant: ['tabular-nums']}, + a.font_bold, + {lineHeight: 1.25}, + ]}> + {`${minutes}:${seconds}`} + </Text> + </View> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx new file mode 100644 index 000000000..88879d45a --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -0,0 +1,210 @@ +import React, {useRef} from 'react' +import {Pressable, StyleProp, View, ViewStyle} from 'react-native' +import {AppBskyEmbedVideo} from '@atproto/api' +import {BlueskyVideoView} from '@haileyok/bluesky-video' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_30} from '#/lib/constants' +import {useAutoplayDisabled} from '#/state/preferences' +import {atoms as a, useTheme} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' +import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' +import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {TimeIndicator} from './TimeIndicator' + +export const VideoEmbedInnerNative = React.forwardRef( + function VideoEmbedInnerNative( + { + embed, + setStatus, + setIsLoading, + setIsActive, + }: { + embed: AppBskyEmbedVideo.View + setStatus: (status: 'playing' | 'paused') => void + setIsLoading: (isLoading: boolean) => void + setIsActive: (isActive: boolean) => void + }, + ref: React.Ref<{togglePlayback: () => void}>, + ) { + const {_} = useLingui() + const videoRef = useRef<BlueskyVideoView>(null) + const autoplayDisabled = useAutoplayDisabled() + const isWithinMessage = useIsWithinMessage() + const [muted, setMuted] = useVideoMuteState() + + const [isPlaying, setIsPlaying] = React.useState(false) + const [timeRemaining, setTimeRemaining] = React.useState(0) + const [error, setError] = React.useState<string>() + + React.useImperativeHandle(ref, () => ({ + togglePlayback: () => { + videoRef.current?.togglePlayback() + }, + })) + + if (error) { + throw new Error(error) + } + + return ( + <View style={[a.flex_1, a.relative]}> + <BlueskyVideoView + url={embed.playlist} + autoplay={!autoplayDisabled && !isWithinMessage} + beginMuted={autoplayDisabled ? false : muted} + style={[a.rounded_sm]} + onActiveChange={e => { + setIsActive(e.nativeEvent.isActive) + }} + onLoadingChange={e => { + setIsLoading(e.nativeEvent.isLoading) + }} + onMutedChange={e => { + setMuted(e.nativeEvent.isMuted) + }} + onStatusChange={e => { + setStatus(e.nativeEvent.status) + setIsPlaying(e.nativeEvent.status === 'playing') + }} + onTimeRemainingChange={e => { + setTimeRemaining(e.nativeEvent.timeRemaining) + }} + onError={e => { + setError(e.nativeEvent.error) + }} + ref={videoRef} + accessibilityLabel={ + embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) + } + accessibilityHint="" + /> + <VideoControls + enterFullscreen={() => { + videoRef.current?.enterFullscreen(true) + }} + toggleMuted={() => { + videoRef.current?.toggleMuted() + }} + togglePlayback={() => { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + timeRemaining={timeRemaining} + /> + <MediaInsetBorder /> + </View> + ) + }, +) + +function VideoControls({ + enterFullscreen, + toggleMuted, + togglePlayback, + timeRemaining, + isPlaying, +}: { + enterFullscreen: () => void + toggleMuted: () => void + togglePlayback: () => void + timeRemaining: number + isPlaying: boolean +}) { + const {_} = useLingui() + const t = useTheme() + const [muted] = useVideoMuteState() + + // show countdown when: + // 1. timeRemaining is a number - was seeing NaNs + // 2. duration is greater than 0 - means metadata has loaded + // 3. we're less than 5 second into the video + const showTime = !isNaN(timeRemaining) + + return ( + <View style={[a.absolute, a.inset_0]}> + <Pressable + onPress={enterFullscreen} + style={a.flex_1} + accessibilityLabel={_(msg`Video`)} + accessibilityHint={_(msg`Enters full screen`)} + accessibilityRole="button" + /> + <ControlButton + onPress={togglePlayback} + label={isPlaying ? _(msg`Pause`) : _(msg`Play`)} + accessibilityHint={_(msg`Plays or pauses the video`)} + style={{left: 6}}> + {isPlaying ? ( + <PauseIcon width={13} fill={t.palette.white} /> + ) : ( + <PlayIcon width={13} fill={t.palette.white} /> + )} + </ControlButton> + {showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />} + + <ControlButton + onPress={toggleMuted} + label={ + muted + ? _(msg({message: `Unmute`, context: 'video'})) + : _(msg({message: `Mute`, context: 'video'})) + } + accessibilityHint={_(msg`Toggles the sound`)} + style={{right: 6}}> + {muted ? ( + <MuteIcon width={13} fill={t.palette.white} /> + ) : ( + <UnmuteIcon width={13} fill={t.palette.white} /> + )} + </ControlButton> + </View> + ) +} + +function ControlButton({ + onPress, + children, + label, + accessibilityHint, + style, +}: { + onPress: () => void + children: React.ReactNode + label: string + accessibilityHint: string + style?: StyleProp<ViewStyle> +}) { + return ( + <View + style={[ + a.absolute, + a.rounded_full, + a.justify_center, + { + backgroundColor: 'rgba(0, 0, 0, 0.5)', + paddingHorizontal: 4, + paddingVertical: 4, + bottom: 6, + minHeight: 21, + minWidth: 21, + }, + style, + ]}> + <Pressable + onPress={onPress} + style={a.flex_1} + accessibilityLabel={label} + accessibilityHint={accessibilityHint} + accessibilityRole="button" + hitSlop={HITSLOP_30}> + {children} + </Pressable> + </View> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx new file mode 100644 index 000000000..2760c7faf --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerNative() { + throw new Error('VideoEmbedInnerNative may not be used on web.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx new file mode 100644 index 000000000..8664aae14 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerWeb() { + throw new Error('VideoEmbedInnerWeb may not be used on native.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx new file mode 100644 index 000000000..ce3a7b2c9 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -0,0 +1,307 @@ +import {useEffect, useId, useRef, useState} from 'react' +import {View} from 'react-native' +import {type AppBskyEmbedVideo} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type * as HlsTypes from 'hls.js' + +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {atoms as a} from '#/alf' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import * as BandwidthEstimate from './bandwidth-estimate' +import {Controls} from './web-controls/VideoControls' + +export function VideoEmbedInnerWeb({ + embed, + active, + setActive, + onScreen, + lastKnownTime, +}: { + embed: AppBskyEmbedVideo.View + active: boolean + setActive: () => void + onScreen: boolean + lastKnownTime: React.MutableRefObject<number | undefined> +}) { + const containerRef = useRef<HTMLDivElement>(null) + const videoRef = useRef<HTMLVideoElement>(null) + const [focused, setFocused] = useState(false) + const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) + const [hlsLoading, setHlsLoading] = useState(false) + const figId = useId() + const {_} = useLingui() + + // send error up to error boundary + const [error, setError] = useState<Error | null>(null) + if (error) { + throw error + } + + const hlsRef = useHLS({ + playlist: embed.playlist, + setHasSubtitleTrack, + setError, + videoRef, + setHlsLoading, + }) + + useEffect(() => { + if (lastKnownTime.current && videoRef.current) { + videoRef.current.currentTime = lastKnownTime.current + } + }, [lastKnownTime]) + + return ( + <View + style={[a.flex_1, a.rounded_md, a.overflow_hidden]} + accessibilityLabel={_(msg`Embedded video player`)} + accessibilityHint=""> + <div ref={containerRef} style={{height: '100%', width: '100%'}}> + <figure style={{margin: 0, position: 'absolute', inset: 0}}> + <video + ref={videoRef} + poster={embed.thumbnail} + style={{width: '100%', height: '100%', objectFit: 'contain'}} + playsInline + preload="none" + muted={!focused} + aria-labelledby={embed.alt ? figId : undefined} + onTimeUpdate={e => { + lastKnownTime.current = e.currentTarget.currentTime + }} + /> + {embed.alt && ( + <figcaption + id={figId} + style={{ + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + borderWidth: 0, + }}> + {embed.alt} + </figcaption> + )} + </figure> + <Controls + videoRef={videoRef} + hlsRef={hlsRef} + active={active} + setActive={setActive} + focused={focused} + setFocused={setFocused} + hlsLoading={hlsLoading} + onScreen={onScreen} + fullscreenRef={containerRef} + hasSubtitleTrack={hasSubtitleTrack} + /> + </div> + <MediaInsetBorder /> + </View> + ) +} + +export class HLSUnsupportedError extends Error { + constructor() { + super('HLS is not supported') + } +} + +export class VideoNotFoundError extends Error { + constructor() { + super('Video not found') + } +} + +type CachedPromise<T> = Promise<T> & {value: undefined | T} +const promiseForHls = import( + // @ts-ignore + 'hls.js/dist/hls.min' +).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default> +promiseForHls.value = undefined +promiseForHls.then(Hls => { + promiseForHls.value = Hls +}) + +function useHLS({ + playlist, + setHasSubtitleTrack, + setError, + videoRef, + setHlsLoading, +}: { + playlist: string + setHasSubtitleTrack: (v: boolean) => void + setError: (v: Error | null) => void + videoRef: React.RefObject<HTMLVideoElement> + setHlsLoading: (v: boolean) => void +}) { + const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>( + () => promiseForHls.value, + ) + useEffect(() => { + if (!Hls) { + setHlsLoading(true) + promiseForHls.then(loadedHls => { + setHls(() => loadedHls) + setHlsLoading(false) + }) + } + }, [Hls, setHlsLoading]) + + const hlsRef = useRef<HlsTypes.default | undefined>(undefined) + const [lowQualityFragments, setLowQualityFragments] = useState< + HlsTypes.Fragment[] + >([]) + + // purge low quality segments from buffer on next frag change + const handleFragChange = useNonReactiveCallback( + ( + _event: HlsTypes.Events.FRAG_CHANGED, + {frag}: HlsTypes.FragChangedData, + ) => { + if (!Hls) return + if (!hlsRef.current) return + const hls = hlsRef.current + + // if the current quality level goes above 0, flush the low quality segments + if (hls.nextAutoLevel > 0) { + const flushed: HlsTypes.Fragment[] = [] + + for (const lowQualFrag of lowQualityFragments) { + // avoid if close to the current fragment + if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { + continue + } + + hls.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: lowQualFrag.start, + endOffset: lowQualFrag.end, + type: 'video', + }) + + flushed.push(lowQualFrag) + } + + setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) + } + }, + ) + + const flushOnLoop = useNonReactiveCallback(() => { + if (!Hls) return + if (!hlsRef.current) return + const hls = hlsRef.current + // the above callback will catch most stale frags, but there's a corner case - + // if there's only one segment in the video, it won't get flushed because it avoids + // flushing the currently active segment. Therefore, we have to catch it when we loop + if ( + hls.nextAutoLevel > 0 && + lowQualityFragments.length === 1 && + lowQualityFragments[0].start === 0 + ) { + const lowQualFrag = lowQualityFragments[0] + + hls.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: lowQualFrag.start, + endOffset: lowQualFrag.end, + type: 'video', + }) + setLowQualityFragments([]) + } + }) + + useEffect(() => { + if (!videoRef.current) return + if (!Hls) return + if (!Hls.isSupported()) { + throw new HLSUnsupportedError() + } + + const hls = new Hls({ + maxMaxBufferLength: 10, // only load 10s ahead + // note: the amount buffered is affected by both maxBufferLength and maxBufferSize + // it will buffer until it is greater than *both* of those values + // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead + }) + hlsRef.current = hls + + const latestEstimate = BandwidthEstimate.get() + if (latestEstimate !== undefined) { + hls.bandwidthEstimate = latestEstimate + } + + hls.attachMedia(videoRef.current) + hls.loadSource(playlist) + + // manually loop, so if we've flushed the first buffer it doesn't get confused + const abortController = new AbortController() + const {signal} = abortController + const videoNode = videoRef.current + videoNode.addEventListener( + 'ended', + () => { + flushOnLoop() + videoNode.currentTime = 0 + videoNode.play() + }, + {signal}, + ) + + hls.on(Hls.Events.FRAG_LOADED, () => { + BandwidthEstimate.set(hls.bandwidthEstimate) + }) + + hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { + if (data.subtitleTracks.length > 0) { + setHasSubtitleTrack(true) + } + }) + + hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { + if (frag.level === 0) { + setLowQualityFragments(prev => [...prev, frag]) + } + }) + + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) { + if ( + data.details === 'manifestLoadError' && + data.response?.code === 404 + ) { + setError(new VideoNotFoundError()) + } else { + setError(data.error) + } + } else { + console.error(data.error) + } + }) + + hls.on(Hls.Events.FRAG_CHANGED, handleFragChange) + + return () => { + hlsRef.current = undefined + hls.detachMedia() + hls.destroy() + abortController.abort() + } + }, [ + playlist, + setError, + setHasSubtitleTrack, + videoRef, + handleFragChange, + flushOnLoop, + Hls, + ]) + + return hlsRef +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx new file mode 100644 index 000000000..1b46163cc --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text as TypoText} from '#/components/Typography' + +export function Container({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_1, + t.atoms.bg_contrast_25, + a.justify_center, + a.align_center, + a.px_lg, + a.border, + t.atoms.border_contrast_low, + a.rounded_sm, + a.gap_lg, + ]}> + {children} + </View> + ) +} + +export function Text({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + <TypoText + style={[ + a.text_center, + t.atoms.text_contrast_high, + a.text_md, + a.leading_snug, + {maxWidth: 300}, + ]}> + {children} + </TypoText> + ) +} + +export function RetryButton({onPress}: {onPress: () => void}) { + const {_} = useLingui() + + return ( + <Button + onPress={onPress} + size="small" + color="secondary_inverted" + variant="solid" + label={_(msg`Retry`)}> + <ButtonText> + <Trans>Retry</Trans> + </ButtonText> + </Button> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts new file mode 100644 index 000000000..122e10aef --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts @@ -0,0 +1,11 @@ +let latestBandwidthEstimate: number | undefined + +export function get() { + return latestBandwidthEstimate +} + +export function set(estimate: number) { + if (!isNaN(estimate)) { + latestBandwidthEstimate = estimate + } +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx new file mode 100644 index 000000000..1b69a3e25 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import {SvgProps} from 'react-native-svg' + +import {PressableWithHover} from '#/view/com/util/PressableWithHover' +import {atoms as a, useTheme, web} from '#/alf' + +export function ControlButton({ + active, + activeLabel, + inactiveLabel, + activeIcon: ActiveIcon, + inactiveIcon: InactiveIcon, + onPress, +}: { + active: boolean + activeLabel: string + inactiveLabel: string + activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> + inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> + onPress: () => void +}) { + const t = useTheme() + return ( + <PressableWithHover + accessibilityRole="button" + accessibilityLabel={active ? activeLabel : inactiveLabel} + accessibilityHint="" + onPress={onPress} + style={[ + a.p_xs, + a.rounded_full, + web({transition: 'background-color 0.1s'}), + ]} + hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.2)'}}> + {active ? ( + <ActiveIcon fill={t.palette.white} width={20} aria-hidden /> + ) : ( + <InactiveIcon fill={t.palette.white} width={20} aria-hidden /> + )} + </PressableWithHover> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx new file mode 100644 index 000000000..96960bad4 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx @@ -0,0 +1,238 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isFirefox, isTouchDevice} from '#/lib/browser' +import {clamp} from '#/lib/numbers' +import {atoms as a, useTheme, web} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {formatTime} from './utils' + +export function Scrubber({ + duration, + currentTime, + onSeek, + onSeekEnd, + onSeekStart, + seekLeft, + seekRight, + togglePlayPause, + drawFocus, +}: { + duration: number + currentTime: number + onSeek: (time: number) => void + onSeekEnd: () => void + onSeekStart: () => void + seekLeft: () => void + seekRight: () => void + togglePlayPause: () => void + drawFocus: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const [scrubberActive, setScrubberActive] = useState(false) + const { + state: hovered, + onIn: onStartHover, + onOut: onEndHover, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [seekPosition, setSeekPosition] = useState(0) + const isSeekingRef = useRef(false) + const barRef = useRef<HTMLDivElement>(null) + const circleRef = useRef<HTMLDivElement>(null) + + const seek = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + if (!barRef.current) return + const {left, width} = barRef.current.getBoundingClientRect() + const x = evt.clientX + const percent = clamp((x - left) / width, 0, 1) * duration + onSeek(percent) + setSeekPosition(percent) + }, + [duration, onSeek], + ) + + const onPointerDown = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + const target = evt.target + if (target instanceof Element) { + evt.preventDefault() + target.setPointerCapture(evt.pointerId) + isSeekingRef.current = true + seek(evt) + setScrubberActive(true) + onSeekStart() + } + }, + [seek, onSeekStart], + ) + + const onPointerMove = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + if (isSeekingRef.current) { + evt.preventDefault() + seek(evt) + } + }, + [seek], + ) + + const onPointerUp = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + const target = evt.target + if (isSeekingRef.current && target instanceof Element) { + evt.preventDefault() + target.releasePointerCapture(evt.pointerId) + isSeekingRef.current = false + onSeekEnd() + setScrubberActive(false) + } + }, + [onSeekEnd], + ) + + useEffect(() => { + // HACK: there's divergent browser behaviour about what to do when + // a pointerUp event is fired outside the element that captured the + // pointer. Firefox clicks on the element the mouse is over, so we have + // to make everything unclickable while seeking -sfn + if (isFirefox && scrubberActive) { + document.body.classList.add('force-no-clicks') + + return () => { + document.body.classList.remove('force-no-clicks') + } + } + }, [scrubberActive, onSeekEnd]) + + useEffect(() => { + if (!circleRef.current) return + if (focused) { + const abortController = new AbortController() + const {signal} = abortController + circleRef.current.addEventListener( + 'keydown', + evt => { + // space: play/pause + // arrow left: seek backward + // arrow right: seek forward + + if (evt.key === ' ') { + evt.preventDefault() + drawFocus() + togglePlayPause() + } else if (evt.key === 'ArrowLeft') { + evt.preventDefault() + drawFocus() + seekLeft() + } else if (evt.key === 'ArrowRight') { + evt.preventDefault() + drawFocus() + seekRight() + } + }, + {signal}, + ) + + return () => abortController.abort() + } + }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) + + const progress = scrubberActive ? seekPosition : currentTime + const progressPercent = (progress / duration) * 100 + + return ( + <View + testID="scrubber" + style={[ + {height: isTouchDevice ? 32 : 18, width: '100%'}, + a.flex_shrink_0, + a.px_xs, + ]} + onPointerEnter={onStartHover} + onPointerLeave={onEndHover}> + <div + ref={barRef} + style={{ + flex: 1, + display: 'flex', + alignItems: 'center', + position: 'relative', + cursor: scrubberActive ? 'grabbing' : 'grab', + padding: '4px 0', + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp}> + <View + style={[ + a.w_full, + a.rounded_full, + a.overflow_hidden, + {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, + {height: hovered || scrubberActive ? 6 : 3}, + web({transition: 'height 0.1s ease'}), + ]}> + {duration > 0 && ( + <View + style={[ + a.h_full, + {backgroundColor: t.palette.white}, + {width: `${progressPercent}%`}, + ]} + /> + )} + </View> + <div + ref={circleRef} + aria-label={_( + msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`, + )} + role="slider" + aria-valuemax={duration} + aria-valuemin={0} + aria-valuenow={currentTime} + aria-valuetext={_( + msg`${formatTime(currentTime)} of ${formatTime(duration)}`, + )} + tabIndex={0} + onFocus={onFocus} + onBlur={onBlur} + style={{ + position: 'absolute', + height: 16, + width: 16, + left: `calc(${progressPercent}% - 8px)`, + borderRadius: 8, + pointerEvents: 'none', + }}> + <View + style={[ + a.w_full, + a.h_full, + a.rounded_full, + {backgroundColor: t.palette.white}, + { + transform: [ + { + scale: + hovered || scrubberActive || focused + ? scrubberActive + ? 1 + : 0.6 + : 0, + }, + ], + }, + ]} + /> + </div> + </div> + </View> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx new file mode 100644 index 000000000..e2e24ed36 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx @@ -0,0 +1,3 @@ +export function Controls() { + throw new Error('VideoWebControls may not be used on native.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx new file mode 100644 index 000000000..6d14deafc --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -0,0 +1,427 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type Hls from 'hls.js' + +import {isTouchDevice} from '#/lib/browser' +import {clamp} from '#/lib/numbers' +import {isIPhoneWeb} from '#/platform/detection' +import { + useAutoplayDisabled, + useSetSubtitlesEnabled, + useSubtitlesEnabled, +} from '#/state/preferences' +import {atoms as a, useTheme, web} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' +import {useFullscreen} from '#/components/hooks/useFullscreen' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import { + ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, + ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, +} from '#/components/icons/ArrowsDiagonal' +import { + CC_Filled_Corner0_Rounded as CCActiveIcon, + CC_Stroke2_Corner0_Rounded as CCInactiveIcon, +} from '#/components/icons/CC' +import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' +import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {TimeIndicator} from '../TimeIndicator' +import {ControlButton} from './ControlButton' +import {Scrubber} from './Scrubber' +import {formatTime, useVideoElement} from './utils' +import {VolumeControl} from './VolumeControl' + +export function Controls({ + videoRef, + hlsRef, + active, + setActive, + focused, + setFocused, + onScreen, + fullscreenRef, + hlsLoading, + hasSubtitleTrack, +}: { + videoRef: React.RefObject<HTMLVideoElement> + hlsRef: React.RefObject<Hls | undefined> + active: boolean + setActive: () => void + focused: boolean + setFocused: (focused: boolean) => void + onScreen: boolean + fullscreenRef: React.RefObject<HTMLDivElement> + hlsLoading: boolean + hasSubtitleTrack: boolean +}) { + const { + play, + pause, + playing, + muted, + changeMuted, + togglePlayPause, + currentTime, + duration, + buffering, + error, + canPlay, + } = useVideoElement(videoRef) + const t = useTheme() + const {_} = useLingui() + const subtitlesEnabled = useSubtitlesEnabled() + const setSubtitlesEnabled = useSetSubtitlesEnabled() + const { + state: hovered, + onIn: onHover, + onOut: onEndHover, + } = useInteractionState() + const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) + const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) + const showSpinner = hlsLoading || buffering + const { + state: volumeHovered, + onIn: onVolumeHover, + onOut: onVolumeEndHover, + } = useInteractionState() + + const onKeyDown = useCallback(() => { + setInteractingViaKeypress(true) + }, []) + + useEffect(() => { + if (interactingViaKeypress) { + document.addEventListener('click', () => setInteractingViaKeypress(false)) + return () => { + document.removeEventListener('click', () => + setInteractingViaKeypress(false), + ) + } + } + }, [interactingViaKeypress]) + + useEffect(() => { + if (isFullscreen) { + document.documentElement.style.scrollbarGutter = 'unset' + return () => { + document.documentElement.style.removeProperty('scrollbar-gutter') + } + } + }, [isFullscreen]) + + // pause + unfocus when another video is active + useEffect(() => { + if (!active) { + pause() + setFocused(false) + } + }, [active, pause, setFocused]) + + // autoplay/pause based on visibility + const isWithinMessage = useIsWithinMessage() + const autoplayDisabled = useAutoplayDisabled() || isWithinMessage + useEffect(() => { + if (active) { + if (onScreen) { + if (!autoplayDisabled) play() + } else { + pause() + } + } + }, [onScreen, pause, active, play, autoplayDisabled]) + + // use minimal quality when not focused + useEffect(() => { + if (!hlsRef.current) return + if (focused) { + // allow 30s of buffering + hlsRef.current.config.maxMaxBufferLength = 30 + } else { + // back to what we initially set + hlsRef.current.config.maxMaxBufferLength = 10 + } + }, [hlsRef, focused]) + + useEffect(() => { + if (!hlsRef.current) return + if (hasSubtitleTrack && subtitlesEnabled && canPlay) { + hlsRef.current.subtitleTrack = 0 + } else { + hlsRef.current.subtitleTrack = -1 + } + }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) + + // clicking on any button should focus the player, if it's not already focused + const drawFocus = useCallback(() => { + if (!active) { + setActive() + } + setFocused(true) + }, [active, setActive, setFocused]) + + const onPressEmptySpace = useCallback(() => { + if (!focused) { + drawFocus() + if (autoplayDisabled) play() + } else { + togglePlayPause() + } + }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) + + const onPressPlayPause = useCallback(() => { + drawFocus() + togglePlayPause() + }, [drawFocus, togglePlayPause]) + + const onPressSubtitles = useCallback(() => { + drawFocus() + setSubtitlesEnabled(!subtitlesEnabled) + }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) + + const onPressFullscreen = useCallback(() => { + drawFocus() + toggleFullscreen() + }, [drawFocus, toggleFullscreen]) + + const onSeek = useCallback( + (time: number) => { + if (!videoRef.current) return + if (videoRef.current.fastSeek) { + videoRef.current.fastSeek(time) + } else { + videoRef.current.currentTime = time + } + }, + [videoRef], + ) + + const playStateBeforeSeekRef = useRef(false) + + const onSeekStart = useCallback(() => { + drawFocus() + playStateBeforeSeekRef.current = playing + pause() + }, [playing, pause, drawFocus]) + + const onSeekEnd = useCallback(() => { + if (playStateBeforeSeekRef.current) { + play() + } + }, [play]) + + const seekLeft = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime - 5, 0, duration)) + }, [onSeek, videoRef]) + + const seekRight = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime + 5, 0, duration)) + }, [onSeek, videoRef]) + + const [showCursor, setShowCursor] = useState(true) + const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>() + const onPointerMoveEmptySpace = useCallback(() => { + setShowCursor(true) + if (cursorTimeoutRef.current) { + clearTimeout(cursorTimeoutRef.current) + } + cursorTimeoutRef.current = setTimeout(() => { + setShowCursor(false) + onEndHover() + }, 2000) + }, [onEndHover]) + const onPointerLeaveEmptySpace = useCallback(() => { + setShowCursor(false) + if (cursorTimeoutRef.current) { + clearTimeout(cursorTimeoutRef.current) + } + }, []) + + // these are used to trigger the hover state. on mobile, the hover state + // should stick around for a bit after they tap, and if the controls aren't + // present this initial tab should *only* show the controls and not activate anything + + const onPointerDown = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + if (evt.pointerType !== 'mouse' && !hovered) { + evt.preventDefault() + } + clearTimeout(timeoutRef.current) + }, + [hovered], + ) + + const timeoutRef = useRef<ReturnType<typeof setTimeout>>() + + const onHoverWithTimeout = useCallback(() => { + onHover() + clearTimeout(timeoutRef.current) + }, [onHover]) + + const onEndHoverWithTimeout = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + // if touch, end after 3s + // if mouse, end immediately + if (evt.pointerType !== 'mouse') { + setTimeout(onEndHover, 3000) + } else { + onEndHover() + } + }, + [onEndHover], + ) + + const showControls = + ((focused || autoplayDisabled) && !playing) || + (interactingViaKeypress ? hasFocus : hovered) + + return ( + <div + style={{ + position: 'absolute', + inset: 0, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + }} + onClick={evt => { + evt.stopPropagation() + setInteractingViaKeypress(false) + }} + onPointerEnter={onHoverWithTimeout} + onPointerMove={onHoverWithTimeout} + onPointerLeave={onEndHoverWithTimeout} + onPointerDown={onPointerDown} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown}> + <Pressable + accessibilityRole="button" + onPointerEnter={onPointerMoveEmptySpace} + onPointerMove={onPointerMoveEmptySpace} + onPointerLeave={onPointerLeaveEmptySpace} + accessibilityLabel={_( + !focused + ? msg`Unmute video` + : playing + ? msg`Pause video` + : msg`Play video`, + )} + accessibilityHint="" + style={[ + a.flex_1, + web({cursor: showCursor || !playing ? 'pointer' : 'none'}), + ]} + onPress={onPressEmptySpace} + /> + {!showControls && !focused && duration > 0 && ( + <TimeIndicator time={Math.floor(duration - currentTime)} /> + )} + <View + style={[ + a.flex_shrink_0, + a.w_full, + a.px_xs, + web({ + background: + 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', + }), + {opacity: showControls ? 1 : 0}, + {transition: 'opacity 0.2s ease-in-out'}, + ]}> + {(!volumeHovered || isTouchDevice) && ( + <Scrubber + duration={duration} + currentTime={currentTime} + onSeek={onSeek} + onSeekStart={onSeekStart} + onSeekEnd={onSeekEnd} + seekLeft={seekLeft} + seekRight={seekRight} + togglePlayPause={togglePlayPause} + drawFocus={drawFocus} + /> + )} + <View + style={[ + a.flex_1, + a.px_xs, + a.pb_sm, + a.gap_sm, + a.flex_row, + a.align_center, + ]}> + <ControlButton + active={playing} + activeLabel={_(msg`Pause`)} + inactiveLabel={_(msg`Play`)} + activeIcon={PauseIcon} + inactiveIcon={PlayIcon} + onPress={onPressPlayPause} + /> + <View style={a.flex_1} /> + <Text + style={[ + a.px_xs, + {color: t.palette.white, fontVariant: ['tabular-nums']}, + ]}> + {formatTime(currentTime)} / {formatTime(duration)} + </Text> + {hasSubtitleTrack && ( + <ControlButton + active={subtitlesEnabled} + activeLabel={_(msg`Disable subtitles`)} + inactiveLabel={_(msg`Enable subtitles`)} + activeIcon={CCActiveIcon} + inactiveIcon={CCInactiveIcon} + onPress={onPressSubtitles} + /> + )} + <VolumeControl + muted={muted} + changeMuted={changeMuted} + hovered={volumeHovered} + onHover={onVolumeHover} + onEndHover={onVolumeEndHover} + drawFocus={drawFocus} + /> + {!isIPhoneWeb && ( + <ControlButton + active={isFullscreen} + activeLabel={_(msg`Exit fullscreen`)} + inactiveLabel={_(msg`Enter fullscreen`)} + activeIcon={ArrowsInIcon} + inactiveIcon={ArrowsOutIcon} + onPress={onPressFullscreen} + /> + )} + </View> + </View> + {(showSpinner || error) && ( + <View + pointerEvents="none" + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> + {showSpinner && <Loader fill={t.palette.white} size="lg" />} + {error && ( + <Text style={{color: t.palette.white}}> + <Trans>An error occurred</Trans> + </Text> + )} + </View> + )} + </div> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx new file mode 100644 index 000000000..e0b688075 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx @@ -0,0 +1,110 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isSafari, isTouchDevice} from '#/lib/browser' +import {atoms as a} from '#/alf' +import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {ControlButton} from './ControlButton' + +export function VolumeControl({ + muted, + changeMuted, + hovered, + onHover, + onEndHover, + drawFocus, +}: { + muted: boolean + changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void + hovered: boolean + onHover: () => void + onEndHover: () => void + drawFocus: () => void +}) { + const {_} = useLingui() + const [volume, setVolume] = useVideoVolumeState() + + const onVolumeChange = useCallback( + (evt: React.ChangeEvent<HTMLInputElement>) => { + drawFocus() + const vol = sliderVolumeToVideoVolume(Number(evt.target.value)) + setVolume(vol) + changeMuted(vol === 0) + }, + [setVolume, drawFocus, changeMuted], + ) + + const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume) + + const isZeroVolume = volume === 0 + const onPressMute = useCallback(() => { + drawFocus() + if (isZeroVolume) { + setVolume(1) + changeMuted(false) + } else { + changeMuted(prevMuted => !prevMuted) + } + }, [drawFocus, setVolume, isZeroVolume, changeMuted]) + + return ( + <View + onPointerEnter={onHover} + onPointerLeave={onEndHover} + style={[a.relative]}> + {hovered && !isTouchDevice && ( + <Animated.View + entering={FadeIn.duration(100)} + exiting={FadeOut.duration(100)} + style={[a.absolute, a.w_full, {height: 100, bottom: '100%'}]}> + <View + style={[ + a.flex_1, + a.mb_xs, + a.px_2xs, + a.py_xs, + {backgroundColor: 'rgba(0, 0, 0, 0.6)'}, + a.rounded_xs, + a.align_center, + ]}> + <input + type="range" + min={0} + max={100} + value={sliderVolume} + aria-label={_(msg`Volume`)} + style={ + // Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h + isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'} + } + onChange={onVolumeChange} + // @ts-expect-error for old versions of firefox, and then re-using it for targeting the CSS -sfn + orient="vertical" + /> + </View> + </Animated.View> + )} + <ControlButton + active={muted || volume === 0} + activeLabel={_(msg({message: `Unmute`, context: 'video'}))} + inactiveLabel={_(msg({message: `Mute`, context: 'video'}))} + activeIcon={MuteIcon} + inactiveIcon={UnmuteIcon} + onPress={onPressMute} + /> + </View> + ) +} + +function sliderVolumeToVideoVolume(value: number) { + return Math.pow(value / 100, 4) +} + +function videoVolumeToSliderVolume(value: number) { + return Math.round(Math.pow(value, 1 / 4) * 100) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx new file mode 100644 index 000000000..320f61a5f --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx @@ -0,0 +1,240 @@ +import {type RefObject, useCallback, useEffect, useRef, useState} from 'react' + +import {isSafari} from '#/lib/browser' +import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' + +export function useVideoElement(ref: RefObject<HTMLVideoElement>) { + const [playing, setPlaying] = useState(false) + const [muted, setMuted] = useState(true) + const [currentTime, setCurrentTime] = useState(0) + const [volume, setVolume] = useVideoVolumeState() + const [duration, setDuration] = useState(0) + const [buffering, setBuffering] = useState(false) + const [error, setError] = useState(false) + const [canPlay, setCanPlay] = useState(false) + const playWhenReadyRef = useRef(false) + + useEffect(() => { + if (!ref.current) return + ref.current.volume = volume + }, [ref, volume]) + + useEffect(() => { + if (!ref.current) return + + let bufferingTimeout: ReturnType<typeof setTimeout> | undefined + + function round(num: number) { + return Math.round(num * 100) / 100 + } + + // Initial values + setCurrentTime(round(ref.current.currentTime) || 0) + setDuration(round(ref.current.duration) || 0) + setMuted(ref.current.muted) + setPlaying(!ref.current.paused) + setVolume(ref.current.volume) + + const handleTimeUpdate = () => { + if (!ref.current) return + setCurrentTime(round(ref.current.currentTime) || 0) + // HACK: Safari randomly fires `stalled` events when changing between segments + // let's just clear the buffering state if the video is still progressing -sfn + if (isSafari) { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + } + + const handleDurationChange = () => { + if (!ref.current) return + setDuration(round(ref.current.duration) || 0) + } + + const handlePlay = () => { + setPlaying(true) + } + + const handlePause = () => { + setPlaying(false) + } + + const handleVolumeChange = () => { + if (!ref.current) return + setMuted(ref.current.muted) + } + + const handleError = () => { + setError(true) + } + + const handleCanPlay = async () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setCanPlay(true) + + if (!ref.current) return + if (playWhenReadyRef.current) { + try { + await ref.current.play() + } catch (e: any) { + if ( + !e.message?.includes(`The request is not allowed by the user agent`) + ) { + throw e + } + } + playWhenReadyRef.current = false + } + } + + const handleCanPlayThrough = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + + const handleWaiting = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 500) // Delay to avoid frequent buffering state changes + } + + const handlePlaying = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setError(false) + } + + const handleStalled = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 500) // Delay to avoid frequent buffering state changes + } + + const handleEnded = () => { + setPlaying(false) + setBuffering(false) + setError(false) + } + + const abortController = new AbortController() + + ref.current.addEventListener('timeupdate', handleTimeUpdate, { + signal: abortController.signal, + }) + ref.current.addEventListener('durationchange', handleDurationChange, { + signal: abortController.signal, + }) + ref.current.addEventListener('play', handlePlay, { + signal: abortController.signal, + }) + ref.current.addEventListener('pause', handlePause, { + signal: abortController.signal, + }) + ref.current.addEventListener('volumechange', handleVolumeChange, { + signal: abortController.signal, + }) + ref.current.addEventListener('error', handleError, { + signal: abortController.signal, + }) + ref.current.addEventListener('canplay', handleCanPlay, { + signal: abortController.signal, + }) + ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { + signal: abortController.signal, + }) + ref.current.addEventListener('waiting', handleWaiting, { + signal: abortController.signal, + }) + ref.current.addEventListener('playing', handlePlaying, { + signal: abortController.signal, + }) + ref.current.addEventListener('stalled', handleStalled, { + signal: abortController.signal, + }) + ref.current.addEventListener('ended', handleEnded, { + signal: abortController.signal, + }) + + return () => { + abortController.abort() + clearTimeout(bufferingTimeout) + } + }, [ref, setVolume]) + + const play = useCallback(() => { + if (!ref.current) return + + if (ref.current.ended) { + ref.current.currentTime = 0 + } + + if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { + playWhenReadyRef.current = true + } else { + const promise = ref.current.play() + if (promise !== undefined) { + promise.catch(err => { + console.error('Error playing video:', err) + }) + } + } + }, [ref]) + + const pause = useCallback(() => { + if (!ref.current) return + + ref.current.pause() + playWhenReadyRef.current = false + }, [ref]) + + const togglePlayPause = useCallback(() => { + if (!ref.current) return + + if (ref.current.paused) { + play() + } else { + pause() + } + }, [ref, play, pause]) + + const changeMuted = useCallback( + (newMuted: boolean | ((prev: boolean) => boolean)) => { + if (!ref.current) return + + const value = + typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted + ref.current.muted = value + }, + [ref], + ) + + return { + play, + pause, + togglePlayPause, + duration, + currentTime, + playing, + muted, + changeMuted, + buffering, + error, + canPlay, + } +} + +export function formatTime(time: number) { + if (isNaN(time)) { + return '--' + } + + time = Math.round(time) + + const minutes = Math.floor(time / 60) + const seconds = String(time % 60).padStart(2, '0') + + return `${minutes}:${seconds}` +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx new file mode 100644 index 000000000..6343081da --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +const Context = React.createContext<{ + // native + muted: boolean + setMuted: React.Dispatch<React.SetStateAction<boolean>> + // web + volume: number + setVolume: React.Dispatch<React.SetStateAction<number>> +} | null>(null) + +export function Provider({children}: {children: React.ReactNode}) { + const [muted, setMuted] = React.useState(true) + const [volume, setVolume] = React.useState(1) + + const value = React.useMemo( + () => ({ + muted, + setMuted, + volume, + setVolume, + }), + [muted, setMuted, volume, setVolume], + ) + + return <Context.Provider value={value}>{children}</Context.Provider> +} + +export function useVideoVolumeState() { + const context = React.useContext(Context) + if (!context) { + throw new Error( + 'useVideoVolumeState must be used within a VideoVolumeProvider', + ) + } + return [context.volume, context.setVolume] as const +} + +export function useVideoMuteState() { + const context = React.useContext(Context) + if (!context) { + throw new Error( + 'useVideoMuteState must be used within a VideoVolumeProvider', + ) + } + return [context.muted, context.setMuted] as const +} diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx new file mode 100644 index 000000000..fe29ecad6 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -0,0 +1,167 @@ +import React, {useCallback, useState} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {ImageBackground} from 'expo-image' +import {AppBskyEmbedVideo} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useThrottledValue} from '#/components/hooks/useThrottledValue' +import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' +import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative' +import * as VideoFallback from './VideoEmbedInner/VideoFallback' + +interface Props { + embed: AppBskyEmbedVideo.View + crop?: 'none' | 'square' | 'constrained' +} + +export function VideoEmbed({embed, crop}: Props) { + const t = useTheme() + const [key, setKey] = useState(0) + + const renderError = useCallback( + (error: unknown) => ( + <VideoError error={error} retry={() => setKey(key + 1)} /> + ), + [key], + ) + + let aspectRatio: number | undefined + const dims = embed.aspectRatio + if (dims) { + aspectRatio = dims.width / dims.height + if (Number.isNaN(aspectRatio)) { + aspectRatio = undefined + } + } + + let constrained: number | undefined + let max: number | undefined + if (aspectRatio !== undefined) { + const ratio = 1 / 2 // max of 1:2 ratio in feeds + constrained = Math.max(aspectRatio, ratio) + max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread + } + const cropDisabled = crop === 'none' + + const contents = ( + <ErrorBoundary renderError={renderError} key={key}> + <InnerWrapper embed={embed} /> + </ErrorBoundary> + ) + + return ( + <View style={[a.pt_xs]}> + {cropDisabled ? ( + <View + style={[ + a.w_full, + a.overflow_hidden, + {aspectRatio: max ?? 1}, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + {contents} + </View> + ) : ( + <ConstrainedImage + fullBleed={crop === 'square'} + aspectRatio={constrained || 1}> + {contents} + </ConstrainedImage> + )} + </View> + ) +} + +function InnerWrapper({embed}: Props) { + const {_} = useLingui() + const ref = React.useRef<{togglePlayback: () => void}>(null) + + const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>( + 'pending', + ) + const [isLoading, setIsLoading] = React.useState(false) + const [isActive, setIsActive] = React.useState(false) + const showSpinner = useThrottledValue(isActive && isLoading, 100) + + const showOverlay = + !isActive || + isLoading || + (status === 'paused' && !isActive) || + status === 'pending' + + React.useEffect(() => { + if (!isActive && status !== 'pending') { + setStatus('pending') + } + }, [isActive, status]) + + return ( + <> + <VideoEmbedInnerNative + embed={embed} + setStatus={setStatus} + setIsLoading={setIsLoading} + setIsActive={setIsActive} + ref={ref} + /> + <ImageBackground + source={{uri: embed.thumbnail}} + accessibilityIgnoresInvertColors + style={[ + a.absolute, + a.inset_0, + { + backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here, + // the play button won't show up on the first render on android 🥴😮💨 + display: showOverlay ? 'flex' : 'none', + }, + ]} + cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android + > + {showOverlay && ( + <Button + style={[a.flex_1, a.align_center, a.justify_center]} + onPress={() => { + ref.current?.togglePlayback() + }} + label={_(msg`Play video`)} + color="secondary"> + {showSpinner ? ( + <View + style={[ + a.rounded_full, + a.p_xs, + a.align_center, + a.justify_center, + ]}> + <ActivityIndicator size="large" color="white" /> + </View> + ) : ( + <PlayButtonIcon /> + )} + </Button> + )} + </ImageBackground> + </> + ) +} + +function VideoError({retry}: {error: unknown; retry: () => void}) { + return ( + <VideoFallback.Container> + <VideoFallback.Text> + <Trans> + An error occurred while loading the video. Please try again later. + </Trans> + </VideoFallback.Text> + <VideoFallback.RetryButton onPress={retry} /> + </VideoFallback.Container> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx new file mode 100644 index 000000000..53adc3b6a --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -0,0 +1,207 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {AppBskyEmbedVideo} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isFirefox} from '#/lib/browser' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' +import {atoms as a} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' +import {useFullscreen} from '#/components/hooks/useFullscreen' +import { + HLSUnsupportedError, + VideoEmbedInnerWeb, + VideoNotFoundError, +} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb' +import {useActiveVideoWeb} from './ActiveVideoWebContext' +import * as VideoFallback from './VideoEmbedInner/VideoFallback' + +export function VideoEmbed({ + embed, + crop, +}: { + embed: AppBskyEmbedVideo.View + crop?: 'none' | 'square' | 'constrained' +}) { + const ref = useRef<HTMLDivElement>(null) + const {active, setActive, sendPosition, currentActiveView} = + useActiveVideoWeb() + const [onScreen, setOnScreen] = useState(false) + const [isFullscreen] = useFullscreen() + const lastKnownTime = useRef<number | undefined>() + + useEffect(() => { + if (!ref.current) return + if (isFullscreen && !isFirefox) return + const observer = new IntersectionObserver( + entries => { + const entry = entries[0] + if (!entry) return + setOnScreen(entry.isIntersecting) + sendPosition( + entry.boundingClientRect.y + entry.boundingClientRect.height / 2, + ) + }, + {threshold: 0.5}, + ) + observer.observe(ref.current) + return () => observer.disconnect() + }, [sendPosition, isFullscreen]) + + const [key, setKey] = useState(0) + const renderError = useCallback( + (error: unknown) => ( + <VideoError error={error} retry={() => setKey(key + 1)} /> + ), + [key], + ) + + let aspectRatio: number | undefined + const dims = embed.aspectRatio + if (dims) { + aspectRatio = dims.width / dims.height + if (Number.isNaN(aspectRatio)) { + aspectRatio = undefined + } + } + + let constrained: number | undefined + let max: number | undefined + if (aspectRatio !== undefined) { + const ratio = 1 / 2 // max of 1:2 ratio in feeds + constrained = Math.max(aspectRatio, ratio) + max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread + } + const cropDisabled = crop === 'none' + + const contents = ( + <div + ref={ref} + style={{display: 'flex', flex: 1, cursor: 'default'}} + onClick={evt => evt.stopPropagation()}> + <ErrorBoundary renderError={renderError} key={key}> + <ViewportObserver + sendPosition={sendPosition} + isAnyViewActive={currentActiveView !== null}> + <VideoEmbedInnerWeb + embed={embed} + active={active} + setActive={setActive} + onScreen={onScreen} + lastKnownTime={lastKnownTime} + /> + </ViewportObserver> + </ErrorBoundary> + </div> + ) + + return ( + <View style={[a.pt_xs]}> + {cropDisabled ? ( + <View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}> + {contents} + </View> + ) : ( + <ConstrainedImage + fullBleed={crop === 'square'} + aspectRatio={constrained || 1}> + {contents} + </ConstrainedImage> + )} + </View> + ) +} + +/** + * Renders a 100vh tall div and watches it with an IntersectionObserver to + * send the position of the div when it's near the screen. + */ +function ViewportObserver({ + children, + sendPosition, + isAnyViewActive, +}: { + children: React.ReactNode + sendPosition: (position: number) => void + isAnyViewActive: boolean +}) { + const ref = useRef<HTMLDivElement>(null) + const [nearScreen, setNearScreen] = useState(false) + const [isFullscreen] = useFullscreen() + const isWithinMessage = useIsWithinMessage() + + // Send position when scrolling. This is done with an IntersectionObserver + // observing a div of 100vh height + useEffect(() => { + if (!ref.current) return + if (isFullscreen && !isFirefox) return + const observer = new IntersectionObserver( + entries => { + const entry = entries[0] + if (!entry) return + const position = + entry.boundingClientRect.y + entry.boundingClientRect.height / 2 + sendPosition(position) + setNearScreen(entry.isIntersecting) + }, + {threshold: Array.from({length: 101}, (_, i) => i / 100)}, + ) + observer.observe(ref.current) + return () => observer.disconnect() + }, [sendPosition, isFullscreen]) + + // In case scrolling hasn't started yet, send up the position + useEffect(() => { + if (ref.current && !isAnyViewActive) { + const rect = ref.current.getBoundingClientRect() + const position = rect.y + rect.height / 2 + sendPosition(position) + } + }, [isAnyViewActive, sendPosition]) + + return ( + <View style={[a.flex_1, a.flex_row]}> + {nearScreen && children} + <div + ref={ref} + style={{ + // Don't escape bounds when in a message + ...(isWithinMessage + ? {top: 0, height: '100%'} + : {top: 'calc(50% - 50vh)', height: '100vh'}), + position: 'absolute', + left: '50%', + width: 1, + pointerEvents: 'none', + }} + /> + </View> + ) +} + +function VideoError({error, retry}: {error: unknown; retry: () => void}) { + const {_} = useLingui() + + let showRetryButton = true + let text = null + + if (error instanceof VideoNotFoundError) { + text = _(msg`Video not found.`) + } else if (error instanceof HLSUnsupportedError) { + showRetryButton = false + text = _( + msg`Your browser does not support the video format. Please try a different browser.`, + ) + } else { + text = _(msg`An error occurred while loading the video. Please try again.`) + } + + return ( + <VideoFallback.Container> + <VideoFallback.Text>{text}</VideoFallback.Text> + {showRetryButton && <VideoFallback.RetryButton onPress={retry} />} + </VideoFallback.Container> + ) +} diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx new file mode 100644 index 000000000..ace85dc98 --- /dev/null +++ b/src/components/Post/Embed/index.tsx @@ -0,0 +1,332 @@ +import React from 'react' +import {View} from 'react-native' +import { + type $Typed, + type AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + moderatePost, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans} from '@lingui/macro' +import {useQueryClient} from '@tanstack/react-query' + +import {usePalette} from '#/lib/hooks/usePalette' +import {makeProfileLink} from '#/lib/routes/links' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {unstableCacheProfileView} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {Link} from '#/view/com/util/Link' +import {PostMeta} from '#/view/com/util/PostMeta' +import {atoms as a, useTheme} from '#/alf' +import {ContentHider} from '#/components/moderation/ContentHider' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {RichText} from '#/components/RichText' +import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import * as bsky from '#/types/bsky' +import { + type Embed as TEmbed, + type EmbedType, + parseEmbed, +} from '#/types/bsky/post' +import {ExternalEmbed} from './ExternalEmbed' +import {ModeratedFeedEmbed} from './FeedEmbed' +import {ImageEmbed} from './ImageEmbed' +import {ModeratedListEmbed} from './ListEmbed' +import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder' +import { + type CommonProps, + type EmbedProps, + PostEmbedViewContext, + QuoteEmbedViewContext, +} from './types' +import {VideoEmbed} from './VideoEmbed' + +export {PostEmbedViewContext, QuoteEmbedViewContext} from './types' + +export function Embed({embed: rawEmbed, ...rest}: EmbedProps) { + const embed = parseEmbed(rawEmbed) + + switch (embed.type) { + case 'images': + case 'link': + case 'video': { + return <MediaEmbed embed={embed} {...rest} /> + } + case 'feed': + case 'list': + case 'starter_pack': + case 'labeler': + case 'post': + case 'post_not_found': + case 'post_blocked': + case 'post_detached': { + return <RecordEmbed embed={embed} {...rest} /> + } + case 'post_with_media': { + return ( + <View style={rest.style}> + <MediaEmbed embed={embed.media} {...rest} /> + <RecordEmbed embed={embed.view} {...rest} /> + </View> + ) + } + default: { + return null + } + } +} + +function MediaEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'images': { + return ( + <ContentHider modui={rest.moderation?.ui('contentMedia')}> + <ImageEmbed embed={embed} {...rest} /> + </ContentHider> + ) + } + case 'link': { + return ( + <ContentHider modui={rest.moderation?.ui('contentMedia')}> + <ExternalEmbed + link={embed.view.external} + onOpen={rest.onOpen} + style={[a.mt_sm, rest.style]} + /> + </ContentHider> + ) + } + case 'video': { + return ( + <ContentHider modui={rest.moderation?.ui('contentMedia')}> + <VideoEmbed embed={embed.view} /> + </ContentHider> + ) + } + default: { + return null + } + } +} + +function RecordEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'feed': { + return ( + <View style={a.mt_sm}> + <ModeratedFeedEmbed embed={embed} {...rest} /> + </View> + ) + } + case 'list': { + return ( + <View style={a.mt_sm}> + <ModeratedListEmbed embed={embed} /> + </View> + ) + } + case 'starter_pack': { + return ( + <View style={a.mt_sm}> + <StarterPackCard starterPack={embed.view} /> + </View> + ) + } + case 'labeler': { + // not implemented + return null + } + case 'post': { + if (rest.isWithinQuote && !rest.allowNestedQuotes) { + return null + } + + return ( + <QuoteEmbed + {...rest} + embed={embed} + viewContext={ + rest.viewContext === PostEmbedViewContext.Feed + ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia + : undefined + } + isWithinQuote={rest.isWithinQuote} + allowNestedQuotes={rest.allowNestedQuotes} + /> + ) + } + case 'post_not_found': { + return ( + <PostPlaceholderText> + <Trans>Deleted</Trans> + </PostPlaceholderText> + ) + } + case 'post_blocked': { + return ( + <PostPlaceholderText> + <Trans>Blocked</Trans> + </PostPlaceholderText> + ) + } + case 'post_detached': { + return <PostDetachedEmbed embed={embed} /> + } + default: { + return null + } + } +} + +export function PostDetachedEmbed({ + embed, +}: { + embed: EmbedType<'post_detached'> +}) { + const {currentAccount} = useSession() + const isViewerOwner = currentAccount?.did + ? embed.view.uri.includes(currentAccount.did) + : false + + return ( + <PostPlaceholderText> + {isViewerOwner ? ( + <Trans>Removed by you</Trans> + ) : ( + <Trans>Removed by author</Trans> + )} + </PostPlaceholderText> + ) +} + +/* + * Nests parent `Embed` component and therefore must live in this file to avoid + * circular imports. + */ +export function QuoteEmbed({ + embed, + onOpen, + style, + isWithinQuote: parentIsWithinQuote, + allowNestedQuotes: parentAllowNestedQuotes, +}: Omit<CommonProps, 'viewContext'> & { + embed: EmbedType<'post'> + viewContext?: QuoteEmbedViewContext +}) { + const moderationOpts = useModerationOpts() + const quote = React.useMemo<$Typed<AppBskyFeedDefs.PostView>>( + () => ({ + ...embed.view, + $type: 'app.bsky.feed.defs#postView', + record: embed.view.value, + embed: embed.view.embeds?.[0], + }), + [embed], + ) + const moderation = React.useMemo(() => { + return moderationOpts ? moderatePost(quote, moderationOpts) : undefined + }, [quote, moderationOpts]) + + const t = useTheme() + const queryClient = useQueryClient() + const pal = usePalette('default') + const itemUrip = new AtUri(quote.uri) + const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) + const itemTitle = `Post by ${quote.author.handle}` + + const richText = React.useMemo(() => { + if ( + !bsky.dangerousIsType<AppBskyFeedPost.Record>( + quote.record, + AppBskyFeedPost.isRecord, + ) + ) + return undefined + const {text, facets} = quote.record + return text.trim() + ? new RichTextAPI({text: text, facets: facets}) + : undefined + }, [quote.record]) + + const onBeforePress = React.useCallback(() => { + unstableCacheProfileView(queryClient, quote.author) + onOpen?.() + }, [queryClient, quote.author, onOpen]) + + const [hover, setHover] = React.useState(false) + return ( + <View + onPointerEnter={() => { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + <ContentHider + modui={moderation?.ui('contentList')} + style={[ + a.rounded_md, + a.p_md, + a.mt_sm, + a.border, + t.atoms.border_contrast_low, + style, + ]} + childContainerStyle={[a.pt_sm]}> + <SubtleWebHover hover={hover} /> + <Link + hoverStyle={{borderColor: pal.colors.borderLinkHover}} + href={itemHref} + title={itemTitle} + onBeforePress={onBeforePress}> + <View pointerEvents="none"> + <PostMeta + author={quote.author} + moderation={moderation} + showAvatar + postHref={itemHref} + timestamp={quote.indexedAt} + /> + </View> + {moderation ? ( + <PostAlerts + modui={moderation.ui('contentView')} + style={[a.py_xs]} + /> + ) : null} + {richText ? ( + <RichText + value={richText} + style={a.text_md} + numberOfLines={20} + disableLinks + /> + ) : null} + {quote.embed && ( + <Embed + embed={quote.embed} + moderation={moderation} + isWithinQuote={parentIsWithinQuote ?? true} + // already within quote? override nested + allowNestedQuotes={ + parentIsWithinQuote ? false : parentAllowNestedQuotes + } + /> + )} + </Link> + </ContentHider> + </View> + ) +} diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts new file mode 100644 index 000000000..b719d00b4 --- /dev/null +++ b/src/components/Post/Embed/types.ts @@ -0,0 +1,25 @@ +import {type StyleProp, type ViewStyle} from 'react-native' +import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api' + +export enum PostEmbedViewContext { + ThreadHighlighted = 'ThreadHighlighted', + Feed = 'Feed', + FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia', +} + +export enum QuoteEmbedViewContext { + FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia, +} + +export type CommonProps = { + moderation?: ModerationDecision + onOpen?: () => void + style?: StyleProp<ViewStyle> + viewContext?: PostEmbedViewContext + isWithinQuote?: boolean + allowNestedQuotes?: boolean +} + +export type EmbedProps = CommonProps & { + embed?: AppBskyFeedDefs.PostView['embed'] +} |