From 45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 13 Jun 2025 12:05:41 -0500 Subject: 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 --- .../Post/Embed/ExternalEmbed/ExternalGif.tsx | 147 +++++++ .../Post/Embed/ExternalEmbed/ExternalPlayer.tsx | 281 ++++++++++++++ src/components/Post/Embed/ExternalEmbed/Gif.tsx | 224 +++++++++++ src/components/Post/Embed/ExternalEmbed/index.tsx | 182 +++++++++ src/components/Post/Embed/FeedEmbed.tsx | 52 +++ src/components/Post/Embed/ImageEmbed.tsx | 106 +++++ src/components/Post/Embed/LazyQuoteEmbed.tsx | 37 ++ src/components/Post/Embed/ListEmbed.tsx | 42 ++ src/components/Post/Embed/PostPlaceholder.tsx | 33 ++ .../Embed/VideoEmbed/ActiveVideoWebContext.tsx | 114 ++++++ .../VideoEmbed/VideoEmbedInner/TimeIndicator.tsx | 64 +++ .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 210 ++++++++++ .../VideoEmbedInner/VideoEmbedInnerNative.web.tsx | 3 + .../VideoEmbedInner/VideoEmbedInnerWeb.native.tsx | 3 + .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 307 +++++++++++++++ .../VideoEmbed/VideoEmbedInner/VideoFallback.tsx | 61 +++ .../VideoEmbedInner/bandwidth-estimate.ts | 11 + .../VideoEmbedInner/web-controls/ControlButton.tsx | 42 ++ .../VideoEmbedInner/web-controls/Scrubber.tsx | 238 ++++++++++++ .../web-controls/VideoControls.native.tsx | 3 + .../VideoEmbedInner/web-controls/VideoControls.tsx | 427 +++++++++++++++++++++ .../VideoEmbedInner/web-controls/VolumeControl.tsx | 110 ++++++ .../VideoEmbedInner/web-controls/utils.tsx | 240 ++++++++++++ .../Post/Embed/VideoEmbed/VideoVolumeContext.tsx | 47 +++ src/components/Post/Embed/VideoEmbed/index.tsx | 167 ++++++++ src/components/Post/Embed/VideoEmbed/index.web.tsx | 207 ++++++++++ src/components/Post/Embed/index.tsx | 332 ++++++++++++++++ src/components/Post/Embed/types.ts | 25 ++ 28 files changed, 3715 insertions(+) create mode 100644 src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx create mode 100644 src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx create mode 100644 src/components/Post/Embed/ExternalEmbed/Gif.tsx create mode 100644 src/components/Post/Embed/ExternalEmbed/index.tsx create mode 100644 src/components/Post/Embed/FeedEmbed.tsx create mode 100644 src/components/Post/Embed/ImageEmbed.tsx create mode 100644 src/components/Post/Embed/LazyQuoteEmbed.tsx create mode 100644 src/components/Post/Embed/ListEmbed.tsx create mode 100644 src/components/Post/Embed/PostPlaceholder.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/index.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/index.web.tsx create mode 100644 src/components/Post/Embed/index.tsx create mode 100644 src/components/Post/Embed/types.ts (limited to 'src/components/Post') 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(null) + + const load = React.useCallback(() => { + setIsPlayerActive(true) + Image.prefetch(params.playerUri).then(() => { + // Replace the image once it's fetched + setIsPrefetched(true) + }) + }, [params.playerUri]) + + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Don't propagate on web + event.preventDefault() + + // Show consent if this is the first load + if (externalEmbedsPrefs?.[params.source] === undefined) { + consentDialogControl.open() + return + } + // If the player isn't active, we want to activate it and prefetch the gif + if (!isPlayerActive) { + load() + return + } + // Control animation on native + setIsAnimating(prev => { + if (prev) { + if (isNative) { + imageRef.current?.stopAnimating() + } + return false + } else { + if (isNative) { + imageRef.current?.startAnimating() + } + return true + } + }) + }, + [ + consentDialogControl, + externalEmbedsPrefs, + isPlayerActive, + load, + params.source, + ], + ) + + return ( + <> + + + + + + {(!isPrefetched || !isAnimating) && ( + + + + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active + + ) : ( + // Activity indicator while gif loads + + )} + + )} + + + ) +} diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx new file mode 100644 index 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 ( + + + {!isPlayerActive ? ( + + ) : ( + + )} + + + ) +} + +// This renders the webview/youtube player as a separate layer +function Player({ + params, + onLoad, + isPlayerActive, +}: { + isPlayerActive: boolean + params: EmbedPlayerParams + onLoad: () => void +}) { + // ensures we only load what's requested + // when it's a youtube video, we need to allow both bsky.app and youtube.com + const onShouldStartLoadWithRequest = React.useCallback( + (event: ShouldStartLoadRequest) => + event.url === params.playerUri || + (params.source.startsWith('youtube') && + event.url.includes('www.youtube.com')), + [params.playerUri, params.source], + ) + + // Don't show the player until it is active + if (!isPlayerActive) return null + + return ( + + + + ) +} + +// This renders the player area and handles the logic for when to show the player and when to show the overlay +export function ExternalPlayer({ + link, + params, +}: { + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams +}) { + const t = useTheme() + const navigation = useNavigation() + const insets = useSafeAreaInsets() + const windowDims = useWindowDimensions() + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const consentDialogControl = useDialogControl() + + const [isPlayerActive, setPlayerActive] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(true) + + const aspect = React.useMemo(() => { + return getPlayerAspect({ + type: params.type, + width: windowDims.width, + hasThumb: !!link.thumb, + }) + }, [params.type, windowDims.width, link.thumb]) + + const viewRef = useAnimatedRef() + const frameCallback = useFrameCallback(() => { + const measurement = measure(viewRef) + if (!measurement) return + + const {height: winHeight, width: winWidth} = windowDims + + // Get the proper screen height depending on what is going on + const realWinHeight = isNative // If it is native, we always want the larger number + ? winHeight > winWidth + ? winHeight + : winWidth + : winHeight // On web, we always want the actual screen height + + const top = measurement.pageY + const bot = measurement.pageY + measurement.height + + // We can use the same logic on all platforms against the screenHeight that we get above + const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top + + if (!isVisible) { + runOnJS(setPlayerActive)(false) + } + }, false) // False here disables autostarting the callback + + // watch for leaving the viewport due to scrolling + React.useEffect(() => { + // We don't want to do anything if the player isn't active + if (!isPlayerActive) return + + // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will + // continue playing. We need to watch for the blur event + const unsubscribe = navigation.addListener('blur', () => { + setPlayerActive(false) + }) + + // Start watching for changes + frameCallback.setActive(true) + + return () => { + unsubscribe() + frameCallback.setActive(false) + } + }, [navigation, isPlayerActive, frameCallback]) + + const onLoad = React.useCallback(() => { + setIsLoading(false) + }, []) + + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Prevent this from propagating upward on web + event.preventDefault() + + if (externalEmbedsPrefs?.[params.source] === undefined) { + consentDialogControl.open() + return + } + + setPlayerActive(true) + }, + [externalEmbedsPrefs, consentDialogControl, params.source], + ) + + const onAcceptConsent = React.useCallback(() => { + setPlayerActive(true) + }, []) + + return ( + <> + + + + {link.thumb && (!isPlayerActive || isLoading) ? ( + <> + + + + ) : ( + + )} + + + + + ) +} + +const styles = StyleSheet.create({ + overlayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + overlayLayer: { + zIndex: 2, + }, + playerLayer: { + zIndex: 3, + }, + webview: { + backgroundColor: 'transparent', + }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, +}) diff --git a/src/components/Post/Embed/ExternalEmbed/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 ( + + {!isLoaded ? ( + + + + + + ) : !isPlaying ? ( + + ) : undefined} + + ) +} + +export function GifEmbed({ + params, + thumb, + altText, + isPreferredAltText, + hideAlt, + style = {width: '100%'}, +}: { + params: EmbedPlayerParams + thumb: string | undefined + altText: string + isPreferredAltText: boolean + hideAlt?: boolean + style?: StyleProp +}) { + const t = useTheme() + const {_} = useLingui() + const autoplayDisabled = useAutoplayDisabled() + + const playerRef = React.useRef(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 ( + + + + + {!playerState.isPlaying && ( + + )} + {!hideAlt && isPreferredAltText && } + + + ) +} + +function AltText({text}: {text: string}) { + const control = Prompt.usePromptControl() + const largeAltBadge = useLargeAltBadgeEnabled() + + const {_} = useLingui() + return ( + <> + + + ALT + + + + + Alt Text + + {text} + + control.close()} + cta={_(msg`Close`)} + color="secondary" + /> + + + + ) +} + +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 + 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 ( + + + + ) + } + + return ( + + {({hovered}) => ( + + {imageUri && !embedPlayerParams ? ( + + ) : undefined} + + {embedPlayerParams?.isGif ? ( + + ) : embedPlayerParams ? ( + + ) : undefined} + + + + {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && ( + + {link.title || link.uri} + + )} + {link.description ? ( + + {link.description} + + ) : undefined} + + + + + + + {toNiceDomain(link.uri)} + + + + + + )} + + ) +} 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 ( + + ) +} + +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 ( + + + + ) +} + +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[], + 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 ( + + onPress(0, [containerRef], [dims])} + onPressIn={() => onPressIn(0)} + hideBadge={ + rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + + ) + } + + return ( + + + + ) + } +} 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 ? ( + + ) : ( + + ) +} 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 ( + + + + ) +} + +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 ( + + + + ) +} 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 ( + + + + {children} + + + ) +} + +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(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 {children} +} + +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 +}) { + 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 ( + + + {`${minutes}:${seconds}`} + + + ) +} 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(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() + + React.useImperativeHandle(ref, () => ({ + togglePlayback: () => { + videoRef.current?.togglePlayback() + }, + })) + + if (error) { + throw new Error(error) + } + + return ( + + { + 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="" + /> + { + videoRef.current?.enterFullscreen(true) + }} + toggleMuted={() => { + videoRef.current?.toggleMuted() + }} + togglePlayback={() => { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + timeRemaining={timeRemaining} + /> + + + ) + }, +) + +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 ( + + + + {isPlaying ? ( + + ) : ( + + )} + + {showTime && } + + + {muted ? ( + + ) : ( + + )} + + + ) +} + +function ControlButton({ + onPress, + children, + label, + accessibilityHint, + style, +}: { + onPress: () => void + children: React.ReactNode + label: string + accessibilityHint: string + style?: StyleProp +}) { + return ( + + + {children} + + + ) +} 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 +}) { + const containerRef = useRef(null) + const videoRef = useRef(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(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 ( + +
+
+
+ +
+ +
+ ) +} + +export class HLSUnsupportedError extends Error { + constructor() { + super('HLS is not supported') + } +} + +export class VideoNotFoundError extends Error { + constructor() { + super('Video not found') + } +} + +type CachedPromise = Promise & {value: undefined | T} +const promiseForHls = import( + // @ts-ignore + 'hls.js/dist/hls.min' +).then(mod => mod.default) as CachedPromise +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 + setHlsLoading: (v: boolean) => void +}) { + const [Hls, setHls] = useState( + () => promiseForHls.value, + ) + useEffect(() => { + if (!Hls) { + setHlsLoading(true) + promiseForHls.then(loadedHls => { + setHls(() => loadedHls) + setHlsLoading(false) + }) + } + }, [Hls, setHlsLoading]) + + const hlsRef = useRef(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 ( + + {children} + + ) +} + +export function Text({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function RetryButton({onPress}: {onPress: () => void}) { + const {_} = useLingui() + + return ( + + ) +} 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> + inactiveIcon: React.ComponentType> + onPress: () => void +}) { + const t = useTheme() + return ( + + {active ? ( + + ) : ( + + )} + + ) +} 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(null) + const circleRef = useRef(null) + + const seek = useCallback( + (evt: React.PointerEvent) => { + 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) => { + 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) => { + if (isSeekingRef.current) { + evt.preventDefault() + seek(evt) + } + }, + [seek], + ) + + const onPointerUp = useCallback( + (evt: React.PointerEvent) => { + 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 ( + +
+ + {duration > 0 && ( + + )} + +
+ +
+
+
+ ) +} 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 + hlsRef: React.RefObject + active: boolean + setActive: () => void + focused: boolean + setFocused: (focused: boolean) => void + onScreen: boolean + fullscreenRef: React.RefObject + 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>() + 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) => { + if (evt.pointerType !== 'mouse' && !hovered) { + evt.preventDefault() + } + clearTimeout(timeoutRef.current) + }, + [hovered], + ) + + const timeoutRef = useRef>() + + const onHoverWithTimeout = useCallback(() => { + onHover() + clearTimeout(timeoutRef.current) + }, [onHover]) + + const onEndHoverWithTimeout = useCallback( + (evt: React.PointerEvent) => { + // 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 ( +
{ + evt.stopPropagation() + setInteractingViaKeypress(false) + }} + onPointerEnter={onHoverWithTimeout} + onPointerMove={onHoverWithTimeout} + onPointerLeave={onEndHoverWithTimeout} + onPointerDown={onPointerDown} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown}> + + {!showControls && !focused && duration > 0 && ( + + )} + + {(!volumeHovered || isTouchDevice) && ( + + )} + + + + + {formatTime(currentTime)} / {formatTime(duration)} + + {hasSubtitleTrack && ( + + )} + + {!isIPhoneWeb && ( + + )} + + + {(showSpinner || error) && ( + + {showSpinner && } + {error && ( + + An error occurred + + )} + + )} +
+ ) +} 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) => { + 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 ( + + {hovered && !isTouchDevice && ( + + + + + + )} + + + ) +} + +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) { + 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 | 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> + // web + volume: number + setVolume: React.Dispatch> +} | 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 {children} +} + +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) => ( + 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 = ( + + + + ) + + return ( + + {cropDisabled ? ( + + {contents} + + ) : ( + + {contents} + + )} + + ) +} + +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 ( + <> + + + {showOverlay && ( + + )} + + + ) +} + +function VideoError({retry}: {error: unknown; retry: () => void}) { + return ( + + + + An error occurred while loading the video. Please try again later. + + + + + ) +} 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(null) + const {active, setActive, sendPosition, currentActiveView} = + useActiveVideoWeb() + const [onScreen, setOnScreen] = useState(false) + const [isFullscreen] = useFullscreen() + const lastKnownTime = useRef() + + 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) => ( + 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 = ( +
evt.stopPropagation()}> + + + + + +
+ ) + + return ( + + {cropDisabled ? ( + + {contents} + + ) : ( + + {contents} + + )} + + ) +} + +/** + * 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(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 ( + + {nearScreen && children} +
+ + ) +} + +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 ( + + {text} + {showRetryButton && } + + ) +} 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 + } + case 'feed': + case 'list': + case 'starter_pack': + case 'labeler': + case 'post': + case 'post_not_found': + case 'post_blocked': + case 'post_detached': { + return + } + case 'post_with_media': { + return ( + + + + + ) + } + default: { + return null + } + } +} + +function MediaEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'images': { + return ( + + + + ) + } + case 'link': { + return ( + + + + ) + } + case 'video': { + return ( + + + + ) + } + default: { + return null + } + } +} + +function RecordEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'feed': { + return ( + + + + ) + } + case 'list': { + return ( + + + + ) + } + case 'starter_pack': { + return ( + + + + ) + } + case 'labeler': { + // not implemented + return null + } + case 'post': { + if (rest.isWithinQuote && !rest.allowNestedQuotes) { + return null + } + + return ( + + ) + } + case 'post_not_found': { + return ( + + Deleted + + ) + } + case 'post_blocked': { + return ( + + Blocked + + ) + } + case 'post_detached': { + return + } + 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 ( + + {isViewerOwner ? ( + Removed by you + ) : ( + Removed by author + )} + + ) +} + +/* + * 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 & { + embed: EmbedType<'post'> + viewContext?: QuoteEmbedViewContext +}) { + const moderationOpts = useModerationOpts() + const quote = React.useMemo<$Typed>( + () => ({ + ...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( + 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 ( + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + + + + + + {moderation ? ( + + ) : null} + {richText ? ( + + ) : null} + {quote.embed && ( + + )} + + + + ) +} 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 + viewContext?: PostEmbedViewContext + isWithinQuote?: boolean + allowNestedQuotes?: boolean +} + +export type EmbedProps = CommonProps & { + embed?: AppBskyFeedDefs.PostView['embed'] +} -- cgit 1.4.1