diff --git a/src/App.web.tsx b/src/App.web.tsx index 959a69c540..9e98871a5c 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -49,8 +49,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {Provider as TrendingConfigProvider} from '#/state/trending-config' -import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' -import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' @@ -60,6 +58,8 @@ import {NuxDialogs} from '#/components/dialogs/nuxs' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' +import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' +import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' /** diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index 731bc551b8..53adc3b6aa 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -7,14 +7,14 @@ 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 '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' -import {atoms as a} from '#/alf' -import {useIsWithinMessage} from '#/components/dms/MessageContext' -import {useFullscreen} from '#/components/hooks/useFullscreen' +} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb' import {useActiveVideoWeb} from './ActiveVideoWebContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index fd36754190..013dcdbc2d 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -18,7 +18,6 @@ import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' import {Link} from '#/view/com/util/Link' import {} from '#/view/com/util/post-embeds/types' -import {VideoEmbed} from '#/view/com/util/post-embeds/VideoEmbed' import {PostMeta} from '#/view/com/util/PostMeta' import {atoms as a, useTheme} from '#/alf' import {ContentHider} from '#/components/moderation/ContentHider' @@ -39,6 +38,7 @@ import { PostEmbedViewContext, QuoteEmbedViewContext, } from './types' +import {VideoEmbed} from './VideoEmbed' export {PostEmbedViewContext, QuoteEmbedViewContext} from './types' diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index bd99b9f28e..7bf2032b43 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -21,8 +21,8 @@ import * as TextField from '#/components/forms/TextField' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif' import {Text} from '#/components/Typography' -import {GifEmbed} from '../util/post-embeds/GifEmbed' import {AltTextReminder} from './photos/Gallery' export function GifAltTextDialog({ diff --git a/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx b/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx deleted file mode 100644 index a038403b22..0000000000 --- a/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react' -import {useWindowDimensions} from 'react-native' - -import {isNative, isWeb} from '#/platform/detection' - -const Context = React.createContext<{ - activeViewId: string | null - setActiveView: (viewId: string) => void - sendViewPosition: (viewId: string, y: number) => void -} | null>(null) - -export function Provider({children}: {children: React.ReactNode}) { - if (!isWeb) { - throw new Error('ActiveVideoWebContext may only be used on web.') - } - - const [activeViewId, setActiveViewId] = useState(null) - const activeViewLocationRef = useRef(Infinity) - const {height: windowHeight} = useWindowDimensions() - - // minimising re-renders by using refs - const manuallySetRef = useRef(false) - const activeViewIdRef = useRef(activeViewId) - useEffect(() => { - activeViewIdRef.current = activeViewId - }, [activeViewId]) - - const setActiveView = useCallback( - (viewId: string) => { - setActiveViewId(viewId) - manuallySetRef.current = true - // we don't know the exact position, but it's definitely on screen - // so just guess that it's in the middle. Any value is fine - // so long as it's not offscreen - activeViewLocationRef.current = windowHeight / 2 - }, - [windowHeight], - ) - - const sendViewPosition = useCallback( - (viewId: string, y: number) => { - if (isNative) return - - if (viewId === activeViewIdRef.current) { - activeViewLocationRef.current = y - } else { - if ( - distanceToIdealPosition(y) < - distanceToIdealPosition(activeViewLocationRef.current) - ) { - // if the old view was manually set, only usurp if the old view is offscreen - if ( - manuallySetRef.current && - withinViewport(activeViewLocationRef.current) - ) { - return - } - - setActiveViewId(viewId) - activeViewLocationRef.current = y - manuallySetRef.current = false - } - } - - function distanceToIdealPosition(yPos: number) { - return Math.abs(yPos - windowHeight / 2.5) - } - - function withinViewport(yPos: number) { - return yPos > 0 && yPos < windowHeight - } - }, - [windowHeight], - ) - - const value = useMemo( - () => ({ - activeViewId, - setActiveView, - sendViewPosition, - }), - [activeViewId, setActiveView, sendViewPosition], - ) - - return {children} -} - -export function useActiveVideoWeb() { - const context = React.useContext(Context) - if (!context) { - throw new Error( - 'useActiveVideoWeb must be used within a ActiveVideoWebProvider', - ) - } - - const {activeViewId, setActiveView, sendViewPosition} = context - const id = useId() - - return { - active: activeViewId === id, - setActive: () => { - setActiveView(id) - }, - currentActiveView: activeViewId, - sendPosition: (y: number) => sendViewPosition(id, y), - } -} diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx deleted file mode 100644 index 39c1d109e1..0000000000 --- a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx +++ /dev/null @@ -1,147 +0,0 @@ -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 ExternalGifEmbed({ - link, - params, -}: { - link: AppBskyEmbedExternal.ViewExternal - params: EmbedPlayerParams -}) { - const t = useTheme() - const externalEmbedsPrefs = useExternalEmbedsPrefs() - const {_} = useLingui() - const consentDialogControl = useDialogControl() - - // Tracking if the placer has been activated - const [isPlayerActive, setIsPlayerActive] = React.useState(false) - // Tracking whether the gif has been loaded yet - const [isPrefetched, setIsPrefetched] = React.useState(false) - // Tracking whether the image is animating - const [isAnimating, setIsAnimating] = React.useState(true) - - // Used for controlling animation - const imageRef = React.useRef(null) - - const load = React.useCallback(() => { - setIsPlayerActive(true) - Image.prefetch(params.playerUri).then(() => { - // Replace the image once it's fetched - setIsPrefetched(true) - }) - }, [params.playerUri]) - - const onPlayPress = React.useCallback( - (event: GestureResponderEvent) => { - // Don't propagate on web - event.preventDefault() - - // Show consent if this is the first load - if (externalEmbedsPrefs?.[params.source] === undefined) { - consentDialogControl.open() - return - } - // If the player isn't active, we want to activate it and prefetch the gif - if (!isPlayerActive) { - load() - return - } - // Control animation on native - setIsAnimating(prev => { - if (prev) { - if (isNative) { - imageRef.current?.stopAnimating() - } - return false - } else { - if (isNative) { - imageRef.current?.startAnimating() - } - return true - } - }) - }, - [ - consentDialogControl, - externalEmbedsPrefs, - isPlayerActive, - load, - params.source, - ], - ) - - return ( - <> - - - - - - {(!isPrefetched || !isAnimating) && ( - - - - {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active - - ) : ( - // Activity indicator while gif loads - - )} - - )} - - - ) -} diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx deleted file mode 100644 index 5b05cd963e..0000000000 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React, {useCallback} from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' -import {Image} from 'expo-image' -import {AppBskyEmbedExternal} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {parseAltFromGIFDescription} from '#/lib/gif-alt-text' -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 {ExternalGifEmbed} from '#/view/com/util/post-embeds/ExternalGifEmbed' -import {ExternalPlayer} from '#/view/com/util/post-embeds/ExternalPlayerEmbed' -import {GifEmbed} from '#/view/com/util/post-embeds/GifEmbed' -import {atoms as a, useTheme} from '#/alf' -import {Divider} from '#/components/Divider' -import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' -import {Link} from '#/components/Link' -import {Text} from '#/components/Typography' - -export const ExternalLinkEmbed = ({ - link, - onOpen, - style, - hideAlt, -}: { - link: AppBskyEmbedExternal.ViewExternal - onOpen?: () => void - style?: StyleProp - hideAlt?: boolean -}) => { - const {_} = useLingui() - const t = useTheme() - 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 onShareExternal = useCallback(() => { - if (link.uri && isNative) { - shareUrl(link.uri) - } - }, [link.uri]) - - if (embedPlayerParams?.source === 'tenor') { - const parsedAlt = parseAltFromGIFDescription(link.description) - return ( - - - - ) - } - - return ( - - {({hovered}) => ( - - {imageUri && !embedPlayerParams ? ( - - ) : undefined} - - {embedPlayerParams?.isGif ? ( - - ) : embedPlayerParams ? ( - - ) : undefined} - - - - {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && ( - - {link.title || link.uri} - - )} - {link.description ? ( - - {link.description} - - ) : undefined} - - - - - - - {toNiceDomain(link.uri)} - - - - - - )} - - ) -} diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx deleted file mode 100644 index e78abdf176..0000000000 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ /dev/null @@ -1,281 +0,0 @@ -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 {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' -import {EventStopper} from '../EventStopper' - -interface ShouldStartLoadRequest { - url: string -} - -// This renders the overlay when the player is either inactive or loading as a separate layer -function PlaceholderOverlay({ - isLoading, - isPlayerActive, - onPress, -}: { - isLoading: boolean - isPlayerActive: boolean - onPress: (event: GestureResponderEvent) => void -}) { - const {_} = useLingui() - - // If the player is active and not loading, we don't want to show the overlay. - if (isPlayerActive && !isLoading) return null - - return ( - - - {!isPlayerActive ? ( - - ) : ( - - )} - - - ) -} - -// This renders the webview/youtube player as a separate layer -function Player({ - params, - onLoad, - isPlayerActive, -}: { - isPlayerActive: boolean - params: EmbedPlayerParams - onLoad: () => void -}) { - // ensures we only load what's requested - // when it's a youtube video, we need to allow both bsky.app and youtube.com - const onShouldStartLoadWithRequest = React.useCallback( - (event: ShouldStartLoadRequest) => - event.url === params.playerUri || - (params.source.startsWith('youtube') && - event.url.includes('www.youtube.com')), - [params.playerUri, params.source], - ) - - // Don't show the player until it is active - if (!isPlayerActive) return null - - return ( - - - - ) -} - -// This renders the player area and handles the logic for when to show the player and when to show the overlay -export function ExternalPlayer({ - link, - params, -}: { - link: AppBskyEmbedExternal.ViewExternal - params: EmbedPlayerParams -}) { - const t = useTheme() - const navigation = useNavigation() - const insets = useSafeAreaInsets() - const windowDims = useWindowDimensions() - const externalEmbedsPrefs = useExternalEmbedsPrefs() - const consentDialogControl = useDialogControl() - - const [isPlayerActive, setPlayerActive] = React.useState(false) - const [isLoading, setIsLoading] = React.useState(true) - - const aspect = React.useMemo(() => { - return getPlayerAspect({ - type: params.type, - width: windowDims.width, - hasThumb: !!link.thumb, - }) - }, [params.type, windowDims.width, link.thumb]) - - const viewRef = useAnimatedRef() - const frameCallback = useFrameCallback(() => { - const measurement = measure(viewRef) - if (!measurement) return - - const {height: winHeight, width: winWidth} = windowDims - - // Get the proper screen height depending on what is going on - const realWinHeight = isNative // If it is native, we always want the larger number - ? winHeight > winWidth - ? winHeight - : winWidth - : winHeight // On web, we always want the actual screen height - - const top = measurement.pageY - const bot = measurement.pageY + measurement.height - - // We can use the same logic on all platforms against the screenHeight that we get above - const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top - - if (!isVisible) { - runOnJS(setPlayerActive)(false) - } - }, false) // False here disables autostarting the callback - - // watch for leaving the viewport due to scrolling - React.useEffect(() => { - // We don't want to do anything if the player isn't active - if (!isPlayerActive) return - - // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will - // continue playing. We need to watch for the blur event - const unsubscribe = navigation.addListener('blur', () => { - setPlayerActive(false) - }) - - // Start watching for changes - frameCallback.setActive(true) - - return () => { - unsubscribe() - frameCallback.setActive(false) - } - }, [navigation, isPlayerActive, frameCallback]) - - const onLoad = React.useCallback(() => { - setIsLoading(false) - }, []) - - const onPlayPress = React.useCallback( - (event: GestureResponderEvent) => { - // Prevent this from propagating upward on web - event.preventDefault() - - if (externalEmbedsPrefs?.[params.source] === undefined) { - consentDialogControl.open() - return - } - - setPlayerActive(true) - }, - [externalEmbedsPrefs, consentDialogControl, params.source], - ) - - const onAcceptConsent = React.useCallback(() => { - setPlayerActive(true) - }, []) - - return ( - <> - - - - {link.thumb && (!isPlayerActive || isLoading) ? ( - <> - - - - ) : ( - - )} - - - - - ) -} - -const styles = StyleSheet.create({ - overlayContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - overlayLayer: { - zIndex: 2, - }, - playerLayer: { - zIndex: 3, - }, - webview: { - backgroundColor: 'transparent', - }, - gifContainer: { - width: '100%', - overflow: 'hidden', - }, -}) diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx deleted file mode 100644 index a839294f1c..0000000000 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import React from 'react' -import { - Pressable, - StyleProp, - StyleSheet, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {HITSLOP_20} from '#/lib/constants' -import {EmbedPlayerParams} from '#/lib/strings/embed-player' -import {isWeb} from '#/platform/detection' -import {useAutoplayDisabled} from '#/state/preferences' -import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' -import {atoms as a, useTheme} from '#/alf' -import {Fill} from '#/components/Fill' -import {Loader} from '#/components/Loader' -import * as Prompt from '#/components/Prompt' -import {Text} from '#/components/Typography' -import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' -import {GifView} from '../../../../../modules/expo-bluesky-gif-view' -import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' - -function PlaybackControls({ - onPress, - isPlaying, - isLoaded, -}: { - onPress: () => void - isPlaying: boolean - isLoaded: boolean -}) { - const {_} = useLingui() - const t = useTheme() - - return ( - - {!isLoaded ? ( - - - - - - ) : !isPlaying ? ( - - ) : undefined} - - ) -} - -export function GifEmbed({ - params, - thumb, - altText, - isPreferredAltText, - hideAlt, - style = {width: '100%'}, -}: { - params: EmbedPlayerParams - thumb: string | undefined - altText: string - isPreferredAltText: boolean - hideAlt?: boolean - style?: StyleProp -}) { - const t = useTheme() - const {_} = useLingui() - const autoplayDisabled = useAutoplayDisabled() - - const playerRef = React.useRef(null) - - const [playerState, setPlayerState] = React.useState<{ - isPlaying: boolean - isLoaded: boolean - }>({ - isPlaying: !autoplayDisabled, - isLoaded: false, - }) - - const onPlayerStateChange = React.useCallback( - (e: GifViewStateChangeEvent) => { - setPlayerState(e.nativeEvent) - }, - [], - ) - - const onPress = React.useCallback(() => { - playerRef.current?.toggleAsync() - }, []) - - return ( - - - - - {!playerState.isPlaying && ( - - )} - {!hideAlt && isPreferredAltText && } - - - ) -} - -function AltText({text}: {text: string}) { - const control = Prompt.usePromptControl() - const largeAltBadge = useLargeAltBadgeEnabled() - - const {_} = useLingui() - return ( - <> - - - ALT - - - - - Alt Text - - {text} - - control.close()} - cta={_(msg`Close`)} - color="secondary" - /> - - - - ) -} - -const styles = StyleSheet.create({ - altContainer: { - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 6, - paddingHorizontal: isWeb ? 8 : 6, - paddingVertical: isWeb ? 6 : 3, - position: 'absolute', - // Related to margin/gap hack. This keeps the alt label in the same position - // on all platforms - right: isWeb ? 8 : 5, - bottom: isWeb ? 8 : 5, - zIndex: 2, - }, - alt: { - color: 'white', - fontSize: isWeb ? 10 : 7, - fontWeight: '600', - }, -}) diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx deleted file mode 100644 index b45027089a..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ /dev/null @@ -1,167 +0,0 @@ -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 {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' -import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' -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 {ErrorBoundary} from '../ErrorBoundary' -import * as VideoFallback from './VideoEmbedInner/VideoFallback' - -interface Props { - embed: AppBskyEmbedVideo.View - crop?: 'none' | 'square' | 'constrained' -} - -export function VideoEmbed({embed, crop}: Props) { - const t = useTheme() - const [key, setKey] = useState(0) - - const renderError = useCallback( - (error: unknown) => ( - setKey(key + 1)} /> - ), - [key], - ) - - let aspectRatio: number | undefined - const dims = embed.aspectRatio - if (dims) { - aspectRatio = dims.width / dims.height - if (Number.isNaN(aspectRatio)) { - aspectRatio = undefined - } - } - - let constrained: number | undefined - let max: number | undefined - if (aspectRatio !== undefined) { - const ratio = 1 / 2 // max of 1:2 ratio in feeds - constrained = Math.max(aspectRatio, ratio) - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread - } - const cropDisabled = crop === 'none' - - const contents = ( - - - - ) - - return ( - - {cropDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} - - ) -} - -function InnerWrapper({embed}: Props) { - const {_} = useLingui() - const ref = React.useRef<{togglePlayback: () => void}>(null) - - const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>( - 'pending', - ) - const [isLoading, setIsLoading] = React.useState(false) - const [isActive, setIsActive] = React.useState(false) - const showSpinner = useThrottledValue(isActive && isLoading, 100) - - const showOverlay = - !isActive || - isLoading || - (status === 'paused' && !isActive) || - status === 'pending' - - React.useEffect(() => { - if (!isActive && status !== 'pending') { - setStatus('pending') - } - }, [isActive, status]) - - return ( - <> - - - {showOverlay && ( - - )} - - - ) -} - -function VideoError({retry}: {error: unknown; retry: () => void}) { - return ( - - - - An error occurred while loading the video. Please try again later. - - - - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx deleted file mode 100644 index b0ded67548..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ /dev/null @@ -1,207 +0,0 @@ -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 {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' -import { - HLSUnsupportedError, - VideoEmbedInnerWeb, - VideoNotFoundError, -} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' -import {atoms as a} from '#/alf' -import {useIsWithinMessage} from '#/components/dms/MessageContext' -import {useFullscreen} from '#/components/hooks/useFullscreen' -import {ErrorBoundary} from '../ErrorBoundary' -import {useActiveVideoWeb} from './ActiveVideoWebContext' -import * as VideoFallback from './VideoEmbedInner/VideoFallback' - -export function VideoEmbed({ - embed, - crop, -}: { - embed: AppBskyEmbedVideo.View - crop?: 'none' | 'square' | 'constrained' -}) { - const ref = useRef(null) - const {active, setActive, sendPosition, currentActiveView} = - useActiveVideoWeb() - const [onScreen, setOnScreen] = useState(false) - const [isFullscreen] = useFullscreen() - const lastKnownTime = useRef() - - useEffect(() => { - if (!ref.current) return - if (isFullscreen && !isFirefox) return - const observer = new IntersectionObserver( - entries => { - const entry = entries[0] - if (!entry) return - setOnScreen(entry.isIntersecting) - sendPosition( - entry.boundingClientRect.y + entry.boundingClientRect.height / 2, - ) - }, - {threshold: 0.5}, - ) - observer.observe(ref.current) - return () => observer.disconnect() - }, [sendPosition, isFullscreen]) - - const [key, setKey] = useState(0) - const renderError = useCallback( - (error: unknown) => ( - setKey(key + 1)} /> - ), - [key], - ) - - let aspectRatio: number | undefined - const dims = embed.aspectRatio - if (dims) { - aspectRatio = dims.width / dims.height - if (Number.isNaN(aspectRatio)) { - aspectRatio = undefined - } - } - - let constrained: number | undefined - let max: number | undefined - if (aspectRatio !== undefined) { - const ratio = 1 / 2 // max of 1:2 ratio in feeds - constrained = Math.max(aspectRatio, ratio) - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread - } - const cropDisabled = crop === 'none' - - const contents = ( -
evt.stopPropagation()}> - - - - - -
- ) - - return ( - - {cropDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} - - ) -} - -/** - * Renders a 100vh tall div and watches it with an IntersectionObserver to - * send the position of the div when it's near the screen. - */ -function ViewportObserver({ - children, - sendPosition, - isAnyViewActive, -}: { - children: React.ReactNode - sendPosition: (position: number) => void - isAnyViewActive: boolean -}) { - const ref = useRef(null) - const [nearScreen, setNearScreen] = useState(false) - const [isFullscreen] = useFullscreen() - const isWithinMessage = useIsWithinMessage() - - // Send position when scrolling. This is done with an IntersectionObserver - // observing a div of 100vh height - useEffect(() => { - if (!ref.current) return - if (isFullscreen && !isFirefox) return - const observer = new IntersectionObserver( - entries => { - const entry = entries[0] - if (!entry) return - const position = - entry.boundingClientRect.y + entry.boundingClientRect.height / 2 - sendPosition(position) - setNearScreen(entry.isIntersecting) - }, - {threshold: Array.from({length: 101}, (_, i) => i / 100)}, - ) - observer.observe(ref.current) - return () => observer.disconnect() - }, [sendPosition, isFullscreen]) - - // In case scrolling hasn't started yet, send up the position - useEffect(() => { - if (ref.current && !isAnyViewActive) { - const rect = ref.current.getBoundingClientRect() - const position = rect.y + rect.height / 2 - sendPosition(position) - } - }, [isAnyViewActive, sendPosition]) - - return ( - - {nearScreen && children} -
- - ) -} - -function VideoError({error, retry}: {error: unknown; retry: () => void}) { - const {_} = useLingui() - - let showRetryButton = true - let text = null - - if (error instanceof VideoNotFoundError) { - text = _(msg`Video not found.`) - } else if (error instanceof HLSUnsupportedError) { - showRetryButton = false - text = _( - msg`Your browser does not support the video format. Please try a different browser.`, - ) - } else { - text = _(msg`An error occurred while loading the video. Please try again.`) - } - - return ( - - {text} - {showRetryButton && } - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx deleted file mode 100644 index 95401309f4..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import {StyleProp, ViewStyle} from 'react-native' -import {View} from 'react-native' -import {msg, plural} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' - -/** - * Absolutely positioned time indicator showing how many seconds are remaining - * Time is in seconds - */ -export function TimeIndicator({ - time, - style, -}: { - time: number - style?: StyleProp -}) { - const t = useTheme() - const {_} = useLingui() - - if (isNaN(time)) { - return null - } - - const minutes = Math.floor(time / 60) - const seconds = String(time % 60).padStart(2, '0') - - return ( - - - {`${minutes}:${seconds}`} - - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx deleted file mode 100644 index 8b44f54483..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ /dev/null @@ -1,210 +0,0 @@ -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 {useVideoMuteState} from '#/view/com/util/post-embeds/VideoVolumeContext' -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 {TimeIndicator} from './TimeIndicator' - -export const VideoEmbedInnerNative = React.forwardRef( - function VideoEmbedInnerNative( - { - embed, - setStatus, - setIsLoading, - setIsActive, - }: { - embed: AppBskyEmbedVideo.View - setStatus: (status: 'playing' | 'paused') => void - setIsLoading: (isLoading: boolean) => void - setIsActive: (isActive: boolean) => void - }, - ref: React.Ref<{togglePlayback: () => void}>, - ) { - const {_} = useLingui() - const videoRef = useRef(null) - const autoplayDisabled = useAutoplayDisabled() - const isWithinMessage = useIsWithinMessage() - const [muted, setMuted] = useVideoMuteState() - - const [isPlaying, setIsPlaying] = React.useState(false) - const [timeRemaining, setTimeRemaining] = React.useState(0) - const [error, setError] = React.useState() - - React.useImperativeHandle(ref, () => ({ - togglePlayback: () => { - videoRef.current?.togglePlayback() - }, - })) - - if (error) { - throw new Error(error) - } - - return ( - - { - setIsActive(e.nativeEvent.isActive) - }} - onLoadingChange={e => { - setIsLoading(e.nativeEvent.isLoading) - }} - onMutedChange={e => { - setMuted(e.nativeEvent.isMuted) - }} - onStatusChange={e => { - setStatus(e.nativeEvent.status) - setIsPlaying(e.nativeEvent.status === 'playing') - }} - onTimeRemainingChange={e => { - setTimeRemaining(e.nativeEvent.timeRemaining) - }} - onError={e => { - setError(e.nativeEvent.error) - }} - ref={videoRef} - accessibilityLabel={ - embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) - } - accessibilityHint="" - /> - { - videoRef.current?.enterFullscreen(true) - }} - toggleMuted={() => { - videoRef.current?.toggleMuted() - }} - togglePlayback={() => { - videoRef.current?.togglePlayback() - }} - isPlaying={isPlaying} - timeRemaining={timeRemaining} - /> - - - ) - }, -) - -function VideoControls({ - enterFullscreen, - toggleMuted, - togglePlayback, - timeRemaining, - isPlaying, -}: { - enterFullscreen: () => void - toggleMuted: () => void - togglePlayback: () => void - timeRemaining: number - isPlaying: boolean -}) { - const {_} = useLingui() - const t = useTheme() - const [muted] = useVideoMuteState() - - // show countdown when: - // 1. timeRemaining is a number - was seeing NaNs - // 2. duration is greater than 0 - means metadata has loaded - // 3. we're less than 5 second into the video - const showTime = !isNaN(timeRemaining) - - return ( - - - - {isPlaying ? ( - - ) : ( - - )} - - {showTime && } - - - {muted ? ( - - ) : ( - - )} - - - ) -} - -function ControlButton({ - onPress, - children, - label, - accessibilityHint, - style, -}: { - onPress: () => void - children: React.ReactNode - label: string - accessibilityHint: string - style?: StyleProp -}) { - return ( - - - {children} - - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx deleted file mode 100644 index 2760c7fafd..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function VideoEmbedInnerNative() { - throw new Error('VideoEmbedInnerNative may not be used on web.') -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx deleted file mode 100644 index 8664aae142..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function VideoEmbedInnerWeb() { - throw new Error('VideoEmbedInnerWeb may not be used on native.') -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx deleted file mode 100644 index e6882a2f60..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import React, {useEffect, useId, 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 type * as HlsTypes from 'hls.js' - -import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {atoms as a} from '#/alf' -import {MediaInsetBorder} from '#/components/MediaInsetBorder' -import {Controls} from './web-controls/VideoControls' - -export function VideoEmbedInnerWeb({ - embed, - active, - setActive, - onScreen, - lastKnownTime, -}: { - embed: AppBskyEmbedVideo.View - active: boolean - setActive: () => void - onScreen: boolean - lastKnownTime: React.MutableRefObject -}) { - const containerRef = useRef(null) - const videoRef = useRef(null) - const [focused, setFocused] = useState(false) - const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) - const [hlsLoading, setHlsLoading] = React.useState(false) - const figId = useId() - const {_} = useLingui() - - // send error up to error boundary - const [error, setError] = useState(null) - if (error) { - throw error - } - - const hlsRef = useHLS({ - playlist: embed.playlist, - setHasSubtitleTrack, - setError, - videoRef, - setHlsLoading, - }) - - useEffect(() => { - if (lastKnownTime.current && videoRef.current) { - videoRef.current.currentTime = lastKnownTime.current - } - }, [lastKnownTime]) - - return ( - -
-
-
- - -
-
- ) -} - -export class HLSUnsupportedError extends Error { - constructor() { - super('HLS is not supported') - } -} - -export class VideoNotFoundError extends Error { - constructor() { - super('Video not found') - } -} - -type CachedPromise = Promise & {value: undefined | T} -const promiseForHls = import( - // @ts-ignore - 'hls.js/dist/hls.min' -).then(mod => mod.default) as CachedPromise -promiseForHls.value = undefined -promiseForHls.then(Hls => { - promiseForHls.value = Hls -}) - -function useHLS({ - playlist, - setHasSubtitleTrack, - setError, - videoRef, - setHlsLoading, -}: { - playlist: string - setHasSubtitleTrack: (v: boolean) => void - setError: (v: Error | null) => void - videoRef: React.RefObject - setHlsLoading: (v: boolean) => void -}) { - const [Hls, setHls] = useState( - () => promiseForHls.value, - ) - useEffect(() => { - if (!Hls) { - setHlsLoading(true) - promiseForHls.then(loadedHls => { - setHls(() => loadedHls) - setHlsLoading(false) - }) - } - }, [Hls, setHlsLoading]) - - const hlsRef = useRef(undefined) - const [lowQualityFragments, setLowQualityFragments] = useState< - HlsTypes.Fragment[] - >([]) - - // purge low quality segments from buffer on next frag change - const handleFragChange = useNonReactiveCallback( - ( - _event: HlsTypes.Events.FRAG_CHANGED, - {frag}: HlsTypes.FragChangedData, - ) => { - if (!Hls) return - if (!hlsRef.current) return - const hls = hlsRef.current - - // if the current quality level goes above 0, flush the low quality segments - if (hls.nextAutoLevel > 0) { - const flushed: HlsTypes.Fragment[] = [] - - for (const lowQualFrag of lowQualityFragments) { - // avoid if close to the current fragment - if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { - continue - } - - hls.trigger(Hls.Events.BUFFER_FLUSHING, { - startOffset: lowQualFrag.start, - endOffset: lowQualFrag.end, - type: 'video', - }) - - flushed.push(lowQualFrag) - } - - setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) - } - }, - ) - - const flushOnLoop = useNonReactiveCallback(() => { - if (!Hls) return - if (!hlsRef.current) return - const hls = hlsRef.current - // the above callback will catch most stale frags, but there's a corner case - - // if there's only one segment in the video, it won't get flushed because it avoids - // flushing the currently active segment. Therefore, we have to catch it when we loop - if ( - hls.nextAutoLevel > 0 && - lowQualityFragments.length === 1 && - lowQualityFragments[0].start === 0 - ) { - const lowQualFrag = lowQualityFragments[0] - - hls.trigger(Hls.Events.BUFFER_FLUSHING, { - startOffset: lowQualFrag.start, - endOffset: lowQualFrag.end, - type: 'video', - }) - setLowQualityFragments([]) - } - }) - - useEffect(() => { - if (!videoRef.current) return - if (!Hls) return - if (!Hls.isSupported()) { - throw new HLSUnsupportedError() - } - - const hls = new Hls({ - maxMaxBufferLength: 10, // only load 10s ahead - // note: the amount buffered is affected by both maxBufferLength and maxBufferSize - // it will buffer until it is greater than *both* of those values - // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead - }) - hlsRef.current = hls - - 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.SUBTITLE_TRACKS_UPDATED, (_event, data) => { - if (data.subtitleTracks.length > 0) { - setHasSubtitleTrack(true) - } - }) - - hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { - if (frag.level === 0) { - setLowQualityFragments(prev => [...prev, frag]) - } - }) - - hls.on(Hls.Events.ERROR, (_event, data) => { - if (data.fatal) { - if ( - data.details === 'manifestLoadError' && - data.response?.code === 404 - ) { - setError(new VideoNotFoundError()) - } else { - setError(data.error) - } - } else { - console.error(data.error) - } - }) - - hls.on(Hls.Events.FRAG_CHANGED, handleFragChange) - - return () => { - hlsRef.current = undefined - hls.detachMedia() - hls.destroy() - abortController.abort() - } - }, [ - playlist, - setError, - setHasSubtitleTrack, - videoRef, - handleFragChange, - flushOnLoop, - Hls, - ]) - - return hlsRef -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx deleted file mode 100644 index 1b46163cce..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import {Text as TypoText} from '#/components/Typography' - -export function Container({children}: {children: React.ReactNode}) { - const t = useTheme() - return ( - - {children} - - ) -} - -export function Text({children}: {children: React.ReactNode}) { - const t = useTheme() - return ( - - {children} - - ) -} - -export function RetryButton({onPress}: {onPress: () => void}) { - const {_} = useLingui() - - return ( - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx deleted file mode 100644 index 6510464453..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react' -import {SvgProps} from 'react-native-svg' - -import {atoms as a, useTheme, web} from '#/alf' -import {PressableWithHover} from '../../../PressableWithHover' - -export function ControlButton({ - active, - activeLabel, - inactiveLabel, - activeIcon: ActiveIcon, - inactiveIcon: InactiveIcon, - onPress, -}: { - active: boolean - activeLabel: string - inactiveLabel: string - activeIcon: React.ComponentType> - inactiveIcon: React.ComponentType> - onPress: () => void -}) { - const t = useTheme() - return ( - - {active ? ( - - ) : ( - - )} - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx deleted file mode 100644 index 96960bad47..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' -import {View} from 'react-native' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {isFirefox, isTouchDevice} from '#/lib/browser' -import {clamp} from '#/lib/numbers' -import {atoms as a, useTheme, web} from '#/alf' -import {useInteractionState} from '#/components/hooks/useInteractionState' -import {formatTime} from './utils' - -export function Scrubber({ - duration, - currentTime, - onSeek, - onSeekEnd, - onSeekStart, - seekLeft, - seekRight, - togglePlayPause, - drawFocus, -}: { - duration: number - currentTime: number - onSeek: (time: number) => void - onSeekEnd: () => void - onSeekStart: () => void - seekLeft: () => void - seekRight: () => void - togglePlayPause: () => void - drawFocus: () => void -}) { - const {_} = useLingui() - const t = useTheme() - const [scrubberActive, setScrubberActive] = useState(false) - const { - state: hovered, - onIn: onStartHover, - onOut: onEndHover, - } = useInteractionState() - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() - const [seekPosition, setSeekPosition] = useState(0) - const isSeekingRef = useRef(false) - const barRef = useRef(null) - const circleRef = useRef(null) - - const seek = useCallback( - (evt: React.PointerEvent) => { - if (!barRef.current) return - const {left, width} = barRef.current.getBoundingClientRect() - const x = evt.clientX - const percent = clamp((x - left) / width, 0, 1) * duration - onSeek(percent) - setSeekPosition(percent) - }, - [duration, onSeek], - ) - - const onPointerDown = useCallback( - (evt: React.PointerEvent) => { - const target = evt.target - if (target instanceof Element) { - evt.preventDefault() - target.setPointerCapture(evt.pointerId) - isSeekingRef.current = true - seek(evt) - setScrubberActive(true) - onSeekStart() - } - }, - [seek, onSeekStart], - ) - - const onPointerMove = useCallback( - (evt: React.PointerEvent) => { - if (isSeekingRef.current) { - evt.preventDefault() - seek(evt) - } - }, - [seek], - ) - - const onPointerUp = useCallback( - (evt: React.PointerEvent) => { - const target = evt.target - if (isSeekingRef.current && target instanceof Element) { - evt.preventDefault() - target.releasePointerCapture(evt.pointerId) - isSeekingRef.current = false - onSeekEnd() - setScrubberActive(false) - } - }, - [onSeekEnd], - ) - - useEffect(() => { - // HACK: there's divergent browser behaviour about what to do when - // a pointerUp event is fired outside the element that captured the - // pointer. Firefox clicks on the element the mouse is over, so we have - // to make everything unclickable while seeking -sfn - if (isFirefox && scrubberActive) { - document.body.classList.add('force-no-clicks') - - return () => { - document.body.classList.remove('force-no-clicks') - } - } - }, [scrubberActive, onSeekEnd]) - - useEffect(() => { - if (!circleRef.current) return - if (focused) { - const abortController = new AbortController() - const {signal} = abortController - circleRef.current.addEventListener( - 'keydown', - evt => { - // space: play/pause - // arrow left: seek backward - // arrow right: seek forward - - if (evt.key === ' ') { - evt.preventDefault() - drawFocus() - togglePlayPause() - } else if (evt.key === 'ArrowLeft') { - evt.preventDefault() - drawFocus() - seekLeft() - } else if (evt.key === 'ArrowRight') { - evt.preventDefault() - drawFocus() - seekRight() - } - }, - {signal}, - ) - - return () => abortController.abort() - } - }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) - - const progress = scrubberActive ? seekPosition : currentTime - const progressPercent = (progress / duration) * 100 - - return ( - -
- - {duration > 0 && ( - - )} - -
- -
-
-
- ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx deleted file mode 100644 index e2e24ed367..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Controls() { - throw new Error('VideoWebControls may not be used on native.') -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx deleted file mode 100644 index 8e134d2217..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' -import {Pressable, View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import type Hls from 'hls.js' - -import {isTouchDevice} from '#/lib/browser' -import {clamp} from '#/lib/numbers' -import {isIPhoneWeb} from '#/platform/detection' -import { - useAutoplayDisabled, - useSetSubtitlesEnabled, - useSubtitlesEnabled, -} from '#/state/preferences' -import {atoms as a, useTheme, web} from '#/alf' -import {useIsWithinMessage} from '#/components/dms/MessageContext' -import {useFullscreen} from '#/components/hooks/useFullscreen' -import {useInteractionState} from '#/components/hooks/useInteractionState' -import { - ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, - ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, -} from '#/components/icons/ArrowsDiagonal' -import { - CC_Filled_Corner0_Rounded as CCActiveIcon, - CC_Stroke2_Corner0_Rounded as CCInactiveIcon, -} from '#/components/icons/CC' -import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' -import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' -import {TimeIndicator} from '../TimeIndicator' -import {ControlButton} from './ControlButton' -import {Scrubber} from './Scrubber' -import {formatTime, useVideoElement} from './utils' -import {VolumeControl} from './VolumeControl' - -export function Controls({ - videoRef, - hlsRef, - active, - setActive, - focused, - setFocused, - onScreen, - fullscreenRef, - hlsLoading, - hasSubtitleTrack, -}: { - videoRef: React.RefObject - hlsRef: React.RefObject - active: boolean - setActive: () => void - focused: boolean - setFocused: (focused: boolean) => void - onScreen: boolean - fullscreenRef: React.RefObject - hlsLoading: boolean - hasSubtitleTrack: boolean -}) { - const { - play, - pause, - playing, - muted, - changeMuted, - togglePlayPause, - currentTime, - duration, - buffering, - error, - canPlay, - } = useVideoElement(videoRef) - const t = useTheme() - const {_} = useLingui() - const subtitlesEnabled = useSubtitlesEnabled() - const setSubtitlesEnabled = useSetSubtitlesEnabled() - const { - state: hovered, - onIn: onHover, - onOut: onEndHover, - } = useInteractionState() - const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) - const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() - const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) - const showSpinner = hlsLoading || buffering - const { - state: volumeHovered, - onIn: onVolumeHover, - onOut: onVolumeEndHover, - } = useInteractionState() - - const onKeyDown = useCallback(() => { - setInteractingViaKeypress(true) - }, []) - - useEffect(() => { - if (interactingViaKeypress) { - document.addEventListener('click', () => setInteractingViaKeypress(false)) - return () => { - document.removeEventListener('click', () => - setInteractingViaKeypress(false), - ) - } - } - }, [interactingViaKeypress]) - - useEffect(() => { - if (isFullscreen) { - document.documentElement.style.scrollbarGutter = 'unset' - return () => { - document.documentElement.style.removeProperty('scrollbar-gutter') - } - } - }, [isFullscreen]) - - // pause + unfocus when another video is active - useEffect(() => { - if (!active) { - pause() - setFocused(false) - } - }, [active, pause, setFocused]) - - // autoplay/pause based on visibility - const isWithinMessage = useIsWithinMessage() - const autoplayDisabled = useAutoplayDisabled() || isWithinMessage - useEffect(() => { - if (active) { - if (onScreen) { - if (!autoplayDisabled) play() - } else { - pause() - } - } - }, [onScreen, pause, active, play, autoplayDisabled]) - - // use minimal quality when not focused - useEffect(() => { - if (!hlsRef.current) return - if (focused) { - // allow 30s of buffering - hlsRef.current.config.maxMaxBufferLength = 30 - } else { - // back to what we initially set - hlsRef.current.config.maxMaxBufferLength = 10 - } - }, [hlsRef, focused]) - - useEffect(() => { - if (!hlsRef.current) return - if (hasSubtitleTrack && subtitlesEnabled && canPlay) { - hlsRef.current.subtitleTrack = 0 - } else { - hlsRef.current.subtitleTrack = -1 - } - }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) - - // clicking on any button should focus the player, if it's not already focused - const drawFocus = useCallback(() => { - if (!active) { - setActive() - } - setFocused(true) - }, [active, setActive, setFocused]) - - const onPressEmptySpace = useCallback(() => { - if (!focused) { - drawFocus() - if (autoplayDisabled) play() - } else { - togglePlayPause() - } - }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) - - const onPressPlayPause = useCallback(() => { - drawFocus() - togglePlayPause() - }, [drawFocus, togglePlayPause]) - - const onPressSubtitles = useCallback(() => { - drawFocus() - setSubtitlesEnabled(!subtitlesEnabled) - }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) - - const onPressFullscreen = useCallback(() => { - drawFocus() - toggleFullscreen() - }, [drawFocus, toggleFullscreen]) - - const onSeek = useCallback( - (time: number) => { - if (!videoRef.current) return - if (videoRef.current.fastSeek) { - videoRef.current.fastSeek(time) - } else { - videoRef.current.currentTime = time - } - }, - [videoRef], - ) - - const playStateBeforeSeekRef = useRef(false) - - const onSeekStart = useCallback(() => { - drawFocus() - playStateBeforeSeekRef.current = playing - pause() - }, [playing, pause, drawFocus]) - - const onSeekEnd = useCallback(() => { - if (playStateBeforeSeekRef.current) { - play() - } - }, [play]) - - const seekLeft = useCallback(() => { - if (!videoRef.current) return - // eslint-disable-next-line @typescript-eslint/no-shadow - const currentTime = videoRef.current.currentTime - // eslint-disable-next-line @typescript-eslint/no-shadow - const duration = videoRef.current.duration || 0 - onSeek(clamp(currentTime - 5, 0, duration)) - }, [onSeek, videoRef]) - - const seekRight = useCallback(() => { - if (!videoRef.current) return - // eslint-disable-next-line @typescript-eslint/no-shadow - const currentTime = videoRef.current.currentTime - // eslint-disable-next-line @typescript-eslint/no-shadow - const duration = videoRef.current.duration || 0 - onSeek(clamp(currentTime + 5, 0, duration)) - }, [onSeek, videoRef]) - - const [showCursor, setShowCursor] = useState(true) - const cursorTimeoutRef = useRef>() - const onPointerMoveEmptySpace = useCallback(() => { - setShowCursor(true) - if (cursorTimeoutRef.current) { - clearTimeout(cursorTimeoutRef.current) - } - cursorTimeoutRef.current = setTimeout(() => { - setShowCursor(false) - onEndHover() - }, 2000) - }, [onEndHover]) - const onPointerLeaveEmptySpace = useCallback(() => { - setShowCursor(false) - if (cursorTimeoutRef.current) { - clearTimeout(cursorTimeoutRef.current) - } - }, []) - - // these are used to trigger the hover state. on mobile, the hover state - // should stick around for a bit after they tap, and if the controls aren't - // present this initial tab should *only* show the controls and not activate anything - - const onPointerDown = useCallback( - (evt: React.PointerEvent) => { - if (evt.pointerType !== 'mouse' && !hovered) { - evt.preventDefault() - } - clearTimeout(timeoutRef.current) - }, - [hovered], - ) - - const timeoutRef = useRef>() - - const onHoverWithTimeout = useCallback(() => { - onHover() - clearTimeout(timeoutRef.current) - }, [onHover]) - - const onEndHoverWithTimeout = useCallback( - (evt: React.PointerEvent) => { - // if touch, end after 3s - // if mouse, end immediately - if (evt.pointerType !== 'mouse') { - setTimeout(onEndHover, 3000) - } else { - onEndHover() - } - }, - [onEndHover], - ) - - const showControls = - ((focused || autoplayDisabled) && !playing) || - (interactingViaKeypress ? hasFocus : hovered) - - return ( -
{ - evt.stopPropagation() - setInteractingViaKeypress(false) - }} - onPointerEnter={onHoverWithTimeout} - onPointerMove={onHoverWithTimeout} - onPointerLeave={onEndHoverWithTimeout} - onPointerDown={onPointerDown} - onFocus={onFocus} - onBlur={onBlur} - onKeyDown={onKeyDown}> - - {!showControls && !focused && duration > 0 && ( - - )} - - {(!volumeHovered || isTouchDevice) && ( - - )} - - - - - {formatTime(currentTime)} / {formatTime(duration)} - - {hasSubtitleTrack && ( - - )} - - {!isIPhoneWeb && ( - - )} - - - {(showSpinner || error) && ( - - {showSpinner && } - {error && ( - - An error occurred - - )} - - )} -
- ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx deleted file mode 100644 index 90ffb9e6b1..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx +++ /dev/null @@ -1,110 +0,0 @@ -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 '../../VideoVolumeContext' -import {ControlButton} from './ControlButton' - -export function VolumeControl({ - muted, - changeMuted, - hovered, - onHover, - onEndHover, - drawFocus, -}: { - muted: boolean - changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void - hovered: boolean - onHover: () => void - onEndHover: () => void - drawFocus: () => void -}) { - const {_} = useLingui() - const [volume, setVolume] = useVideoVolumeState() - - const onVolumeChange = useCallback( - (evt: React.ChangeEvent) => { - drawFocus() - const vol = sliderVolumeToVideoVolume(Number(evt.target.value)) - setVolume(vol) - changeMuted(vol === 0) - }, - [setVolume, drawFocus, changeMuted], - ) - - const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume) - - const isZeroVolume = volume === 0 - const onPressMute = useCallback(() => { - drawFocus() - if (isZeroVolume) { - setVolume(1) - changeMuted(false) - } else { - changeMuted(prevMuted => !prevMuted) - } - }, [drawFocus, setVolume, isZeroVolume, changeMuted]) - - return ( - - {hovered && !isTouchDevice && ( - - - - - - )} - - - ) -} - -function sliderVolumeToVideoVolume(value: number) { - return Math.pow(value / 100, 4) -} - -function videoVolumeToSliderVolume(value: number) { - return Math.round(Math.pow(value, 1 / 4) * 100) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx index 108814ea2b..a1f81ee18b 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx @@ -1,7 +1,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react' import {isSafari} from '#/lib/browser' -import {useVideoVolumeState} from '../../VideoVolumeContext' +import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' export function useVideoElement(ref: React.RefObject) { const [playing, setPlaying] = useState(false) diff --git a/src/view/com/util/post-embeds/VideoVolumeContext.tsx b/src/view/com/util/post-embeds/VideoVolumeContext.tsx deleted file mode 100644 index 6343081da8..0000000000 --- a/src/view/com/util/post-embeds/VideoVolumeContext.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react' - -const Context = React.createContext<{ - // native - muted: boolean - setMuted: React.Dispatch> - // web - volume: number - setVolume: React.Dispatch> -} | null>(null) - -export function Provider({children}: {children: React.ReactNode}) { - const [muted, setMuted] = React.useState(true) - const [volume, setVolume] = React.useState(1) - - const value = React.useMemo( - () => ({ - muted, - setMuted, - volume, - setVolume, - }), - [muted, setMuted, volume, setVolume], - ) - - return {children} -} - -export function useVideoVolumeState() { - const context = React.useContext(Context) - if (!context) { - throw new Error( - 'useVideoVolumeState must be used within a VideoVolumeProvider', - ) - } - return [context.volume, context.setVolume] as const -} - -export function useVideoMuteState() { - const context = React.useContext(Context) - if (!context) { - throw new Error( - 'useVideoMuteState must be used within a VideoVolumeProvider', - ) - } - return [context.muted, context.setMuted] as const -} diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 6f5f9d3ab9..ac25234bc0 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -28,15 +28,15 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' import {atoms as a, useTheme} from '#/alf' import * as ListCard from '#/components/ListCard' +import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' +import {VideoEmbed} from '#/components/Post/Embed/VideoEmbed' import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {ContentHider} from '../../../../components/moderation/ContentHider' import {Dimensions} from '../../lightbox/ImageViewing/@types' import {AutoSizedImage} from '../images/AutoSizedImage' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' -import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed' import {PostEmbedViewContext, QuoteEmbedViewContext} from './types' -import {VideoEmbed} from './VideoEmbed' export * from './types' @@ -223,11 +223,7 @@ export function PostEmbeds({ const link = embed.external return ( - + ) }