Clean up
This commit is contained in:
+2
-2
@@ -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 StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||||
import {Provider as TrendingConfigProvider} from '#/state/trending-config'
|
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 * as Toast from '#/view/com/util/Toast'
|
||||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||||
import {Shell} from '#/view/shell/index'
|
import {Shell} from '#/view/shell/index'
|
||||||
@@ -60,6 +58,8 @@ import {NuxDialogs} from '#/components/dialogs/nuxs'
|
|||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
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'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {isFirefox} from '#/lib/browser'
|
import {isFirefox} from '#/lib/browser'
|
||||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||||
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
|
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 {
|
import {
|
||||||
HLSUnsupportedError,
|
HLSUnsupportedError,
|
||||||
VideoEmbedInnerWeb,
|
VideoEmbedInnerWeb,
|
||||||
VideoNotFoundError,
|
VideoNotFoundError,
|
||||||
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||||
import {atoms as a} from '#/alf'
|
|
||||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
|
||||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
|
||||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {unstableCacheProfileView} from '#/state/queries/profile'
|
|||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {Link} from '#/view/com/util/Link'
|
import {Link} from '#/view/com/util/Link'
|
||||||
import {} from '#/view/com/util/post-embeds/types'
|
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 {PostMeta} from '#/view/com/util/PostMeta'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
@@ -39,6 +38,7 @@ import {
|
|||||||
PostEmbedViewContext,
|
PostEmbedViewContext,
|
||||||
QuoteEmbedViewContext,
|
QuoteEmbedViewContext,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import {VideoEmbed} from './VideoEmbed'
|
||||||
|
|
||||||
export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
|
export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import * as TextField from '#/components/forms/TextField'
|
|||||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
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 {Text} from '#/components/Typography'
|
||||||
import {GifEmbed} from '../util/post-embeds/GifEmbed'
|
|
||||||
import {AltTextReminder} from './photos/Gallery'
|
import {AltTextReminder} from './photos/Gallery'
|
||||||
|
|
||||||
export function GifAltTextDialog({
|
export function GifAltTextDialog({
|
||||||
|
|||||||
@@ -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<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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<ViewStyle>
|
|
||||||
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 (
|
|
||||||
<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={onOpen}
|
|
||||||
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 ? (
|
|
||||||
<ExternalGifEmbed 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<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',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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 (
|
|
||||||
<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',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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) => (
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function VideoEmbedInnerNative() {
|
|
||||||
throw new Error('VideoEmbedInnerNative may not be used on web.')
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function VideoEmbedInnerWeb() {
|
|
||||||
throw new Error('VideoEmbedInnerWeb may not be used on native.')
|
|
||||||
}
|
|
||||||
@@ -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<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] = React.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}
|
|
||||||
/>
|
|
||||||
<MediaInsetBorder />
|
|
||||||
</div>
|
|
||||||
</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
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function Controls() {
|
|
||||||
throw new Error('VideoWebControls may not be used on native.')
|
|
||||||
}
|
|
||||||
@@ -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<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<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)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
|
|
||||||
import {isSafari} from '#/lib/browser'
|
import {isSafari} from '#/lib/browser'
|
||||||
import {useVideoVolumeState} from '../../VideoVolumeContext'
|
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||||
|
|
||||||
export function useVideoElement(ref: React.RefObject<HTMLVideoElement>) {
|
export function useVideoElement(ref: React.RefObject<HTMLVideoElement>) {
|
||||||
const [playing, setPlaying] = useState(false)
|
const [playing, setPlaying] = useState(false)
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -28,15 +28,15 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|||||||
import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
|
import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import * as ListCard from '#/components/ListCard'
|
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 {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||||
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
||||||
import {Dimensions} from '../../lightbox/ImageViewing/@types'
|
import {Dimensions} from '../../lightbox/ImageViewing/@types'
|
||||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
|
||||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||||
import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
|
import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
|
||||||
import {VideoEmbed} from './VideoEmbed'
|
|
||||||
|
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|
||||||
@@ -223,11 +223,7 @@ export function PostEmbeds({
|
|||||||
const link = embed.external
|
const link = embed.external
|
||||||
return (
|
return (
|
||||||
<ContentHider modui={moderation?.ui('contentMedia')}>
|
<ContentHider modui={moderation?.ui('contentMedia')}>
|
||||||
<ExternalLinkEmbed
|
<ExternalEmbed link={link} onOpen={onOpen} style={[a.mt_sm, style]} />
|
||||||
link={link}
|
|
||||||
onOpen={onOpen}
|
|
||||||
style={[a.mt_sm, style]}
|
|
||||||
/>
|
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user