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>
This commit is contained in:
@@ -2,7 +2,7 @@ import {Pressable} from 'react-native'
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
SharedValue,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import {SeenPost} from '#/state/userActionHistory'
|
||||
import {type SeenPost} from '#/state/userActionHistory'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
|
||||
import {
|
||||
atoms as a,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
@@ -27,7 +33,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
|
||||
const MOBILE_CARD_WIDTH = 300
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import {createContext, useCallback, useContext} from 'react'
|
||||
import {GestureResponderEvent, Keyboard, View} from 'react-native'
|
||||
import {type GestureResponderEvent, Keyboard, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useSetDrawerOpen} from '#/state/shell'
|
||||
import {
|
||||
atoms as a,
|
||||
platform,
|
||||
TextStyleProp,
|
||||
type TextStyleProp,
|
||||
useBreakpoints,
|
||||
useGutters,
|
||||
useLayoutBreakpoints,
|
||||
useTheme,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonProps} from '#/components/Button'
|
||||
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
|
||||
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
|
||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
import type {ContextType, ItemContextType} from '#/components/Menu/types'
|
||||
import {type ContextType, type ItemContextType} from '#/components/Menu/types'
|
||||
|
||||
export const Context = React.createContext<ContextType | null>(null)
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function VideoEmbedInnerNative() {
|
||||
throw new Error('VideoEmbedInnerNative may not be used on web.')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function VideoEmbedInnerWeb() {
|
||||
throw new Error('VideoEmbedInnerWeb may not be used on native.')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
let latestBandwidthEstimate: number | undefined
|
||||
|
||||
export function get() {
|
||||
return latestBandwidthEstimate
|
||||
}
|
||||
|
||||
export function set(estimate: number) {
|
||||
if (!isNaN(estimate)) {
|
||||
latestBandwidthEstimate = estimate
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function Controls() {
|
||||
throw new Error('VideoWebControls may not be used on native.')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
|
||||
|
||||
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {Embed} from '#/components/Post/Embed'
|
||||
import {MessageContextProvider} from './MessageContext'
|
||||
|
||||
let MessageItemEmbed = ({
|
||||
embed,
|
||||
}: {
|
||||
embed: AppBskyEmbedRecord.View
|
||||
embed: $Typed<AppBskyEmbedRecord.View>
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const screen = useWindowDimensions()
|
||||
@@ -32,7 +33,7 @@ let MessageItemEmbed = ({
|
||||
}),
|
||||
]}>
|
||||
<View style={{marginTop: tokens.space.sm * -1}}>
|
||||
<PostEmbeds
|
||||
<Embed
|
||||
embed={embed}
|
||||
allowNestedQuotes
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {formatDistance, Locale} from 'date-fns'
|
||||
import {formatDistance, type Locale} from 'date-fns'
|
||||
import {
|
||||
ca,
|
||||
cy,
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
zhTW,
|
||||
} from 'date-fns/locale'
|
||||
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
import {type AppLanguage} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import React, {ComponentProps} from 'react'
|
||||
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api'
|
||||
import React, {type ComponentProps} from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type ModerationCause,
|
||||
type ModerationUI,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
Reference in New Issue
Block a user